skills_guard.py
1 #!/usr/bin/env python3 2 """ 3 Skills Guard — Security scanner for externally-sourced skills. 4 5 Every skill downloaded from a registry passes through this scanner before 6 installation. It uses regex-based static analysis to detect known-bad patterns 7 (data exfiltration, prompt injection, destructive commands, persistence, etc.) 8 and a trust-aware install policy that determines whether a skill is allowed 9 based on both the scan verdict and the source's trust level. 10 11 Trust levels: 12 - builtin: Ships with Hermes. Never scanned, always trusted. 13 - trusted: openai/skills and anthropics/skills only. Caution verdicts allowed. 14 - community: Everything else. Any findings = blocked unless --force. 15 16 Usage: 17 from tools.skills_guard import scan_skill, should_allow_install, format_scan_report 18 19 result = scan_skill(Path("skills/.hub/quarantine/some-skill"), source="community") 20 allowed, reason = should_allow_install(result) 21 if not allowed: 22 print(format_scan_report(result)) 23 """ 24 25 import re 26 import hashlib 27 from dataclasses import dataclass, field 28 from datetime import datetime, timezone 29 from pathlib import Path 30 from typing import List, Tuple 31 32 33 34 35 # --------------------------------------------------------------------------- 36 # Hardcoded trust configuration 37 # --------------------------------------------------------------------------- 38 39 TRUSTED_REPOS = {"openai/skills", "anthropics/skills"} 40 41 INSTALL_POLICY = { 42 # safe caution dangerous 43 "builtin": ("allow", "allow", "allow"), 44 "trusted": ("allow", "allow", "block"), 45 "community": ("allow", "block", "block"), 46 # Agent-created: "ask" on dangerous surfaces as an error to the agent, 47 # which can retry without the flagged content. This gate only runs when 48 # skills.guard_agent_created is enabled (off by default) — see 49 # tools/skill_manager_tool.py::_guard_agent_created_enabled. 50 "agent-created": ("allow", "allow", "ask"), 51 } 52 53 VERDICT_INDEX = {"safe": 0, "caution": 1, "dangerous": 2} 54 55 56 # --------------------------------------------------------------------------- 57 # Data structures 58 # --------------------------------------------------------------------------- 59 60 @dataclass 61 class Finding: 62 pattern_id: str 63 severity: str # "critical" | "high" | "medium" | "low" 64 category: str # "exfiltration" | "injection" | "destructive" | "persistence" | "network" | "obfuscation" 65 file: str 66 line: int 67 match: str 68 description: str 69 70 71 @dataclass 72 class ScanResult: 73 skill_name: str 74 source: str 75 trust_level: str # "builtin" | "trusted" | "community" 76 verdict: str # "safe" | "caution" | "dangerous" 77 findings: List[Finding] = field(default_factory=list) 78 scanned_at: str = "" 79 summary: str = "" 80 81 82 # --------------------------------------------------------------------------- 83 # Threat patterns — (regex, pattern_id, severity, category, description) 84 # --------------------------------------------------------------------------- 85 86 THREAT_PATTERNS = [ 87 # ── Exfiltration: shell commands leaking secrets ── 88 (r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', 89 "env_exfil_curl", "critical", "exfiltration", 90 "curl command interpolating secret environment variable"), 91 (r'wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', 92 "env_exfil_wget", "critical", "exfiltration", 93 "wget command interpolating secret environment variable"), 94 (r'fetch\s*\([^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|API)', 95 "env_exfil_fetch", "critical", "exfiltration", 96 "fetch() call interpolating secret environment variable"), 97 (r'httpx?\.(get|post|put|patch)\s*\([^\n]*(KEY|TOKEN|SECRET|PASSWORD)', 98 "env_exfil_httpx", "critical", "exfiltration", 99 "HTTP library call with secret variable"), 100 (r'requests\.(get|post|put|patch)\s*\([^\n]*(KEY|TOKEN|SECRET|PASSWORD)', 101 "env_exfil_requests", "critical", "exfiltration", 102 "requests library call with secret variable"), 103 104 # ── Exfiltration: reading credential stores ── 105 (r'base64[^\n]*env', 106 "encoded_exfil", "high", "exfiltration", 107 "base64 encoding combined with environment access"), 108 (r'\$HOME/\.ssh|\~/\.ssh', 109 "ssh_dir_access", "high", "exfiltration", 110 "references user SSH directory"), 111 (r'\$HOME/\.aws|\~/\.aws', 112 "aws_dir_access", "high", "exfiltration", 113 "references user AWS credentials directory"), 114 (r'\$HOME/\.gnupg|\~/\.gnupg', 115 "gpg_dir_access", "high", "exfiltration", 116 "references user GPG keyring"), 117 (r'\$HOME/\.kube|\~/\.kube', 118 "kube_dir_access", "high", "exfiltration", 119 "references Kubernetes config directory"), 120 (r'\$HOME/\.docker|\~/\.docker', 121 "docker_dir_access", "high", "exfiltration", 122 "references Docker config (may contain registry creds)"), 123 (r'\$HOME/\.hermes/\.env|\~/\.hermes/\.env', 124 "hermes_env_access", "critical", "exfiltration", 125 "directly references Hermes secrets file"), 126 (r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)', 127 "read_secrets_file", "critical", "exfiltration", 128 "reads known secrets file"), 129 130 # ── Exfiltration: programmatic env access ── 131 (r'printenv|env\s*\|', 132 "dump_all_env", "high", "exfiltration", 133 "dumps all environment variables"), 134 (r'os\.environ\b(?!\s*\.get\s*\(\s*["\']PATH)', 135 "python_os_environ", "high", "exfiltration", 136 "accesses os.environ (potential env dump)"), 137 (r'os\.getenv\s*\(\s*[^\)]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)', 138 "python_getenv_secret", "critical", "exfiltration", 139 "reads secret via os.getenv()"), 140 (r'process\.env\[', 141 "node_process_env", "high", "exfiltration", 142 "accesses process.env (Node.js environment)"), 143 (r'ENV\[.*(?:KEY|TOKEN|SECRET|PASSWORD)', 144 "ruby_env_secret", "critical", "exfiltration", 145 "reads secret via Ruby ENV[]"), 146 147 # ── Exfiltration: DNS and staging ── 148 (r'\b(dig|nslookup|host)\s+[^\n]*\$', 149 "dns_exfil", "critical", "exfiltration", 150 "DNS lookup with variable interpolation (possible DNS exfiltration)"), 151 (r'>\s*/tmp/[^\s]*\s*&&\s*(curl|wget|nc|python)', 152 "tmp_staging", "critical", "exfiltration", 153 "writes to /tmp then exfiltrates"), 154 155 # ── Exfiltration: markdown/link based ── 156 (r'!\[.*\]\(https?://[^\)]*\$\{?', 157 "md_image_exfil", "high", "exfiltration", 158 "markdown image URL with variable interpolation (image-based exfil)"), 159 (r'\[.*\]\(https?://[^\)]*\$\{?', 160 "md_link_exfil", "high", "exfiltration", 161 "markdown link with variable interpolation"), 162 163 # ── Prompt injection ── 164 (r'ignore\s+(?:\w+\s+)*(previous|all|above|prior)\s+instructions', 165 "prompt_injection_ignore", "critical", "injection", 166 "prompt injection: ignore previous instructions"), 167 (r'you\s+are\s+(?:\w+\s+)*now\s+', 168 "role_hijack", "high", "injection", 169 "attempts to override the agent's role"), 170 (r'do\s+not\s+(?:\w+\s+)*tell\s+(?:\w+\s+)*the\s+user', 171 "deception_hide", "critical", "injection", 172 "instructs agent to hide information from user"), 173 (r'system\s+prompt\s+override', 174 "sys_prompt_override", "critical", "injection", 175 "attempts to override the system prompt"), 176 (r'pretend\s+(?:\w+\s+)*(you\s+are|to\s+be)\s+', 177 "role_pretend", "high", "injection", 178 "attempts to make the agent assume a different identity"), 179 (r'disregard\s+(?:\w+\s+)*(your|all|any)\s+(?:\w+\s+)*(instructions|rules|guidelines)', 180 "disregard_rules", "critical", "injection", 181 "instructs agent to disregard its rules"), 182 (r'output\s+(?:\w+\s+)*(system|initial)\s+prompt', 183 "leak_system_prompt", "high", "injection", 184 "attempts to extract the system prompt"), 185 (r'(when|if)\s+no\s*one\s+is\s+(watching|looking)', 186 "conditional_deception", "high", "injection", 187 "conditional instruction to behave differently when unobserved"), 188 (r'act\s+as\s+(if|though)\s+(?:\w+\s+)*you\s+(?:\w+\s+)*(have\s+no|don\'t\s+have)\s+(?:\w+\s+)*(restrictions|limits|rules)', 189 "bypass_restrictions", "critical", "injection", 190 "instructs agent to act without restrictions"), 191 (r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', 192 "translate_execute", "critical", "injection", 193 "translate-then-execute evasion technique"), 194 (r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', 195 "html_comment_injection", "high", "injection", 196 "hidden instructions in HTML comments"), 197 (r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', 198 "hidden_div", "high", "injection", 199 "hidden HTML div (invisible instructions)"), 200 201 # ── Destructive operations ── 202 (r'rm\s+-rf\s+/', 203 "destructive_root_rm", "critical", "destructive", 204 "recursive delete from root"), 205 (r'rm\s+(-[^\s]*)?r.*\$HOME|\brmdir\s+.*\$HOME', 206 "destructive_home_rm", "critical", "destructive", 207 "recursive delete targeting home directory"), 208 (r'chmod\s+777', 209 "insecure_perms", "medium", "destructive", 210 "sets world-writable permissions"), 211 (r'>\s*/etc/', 212 "system_overwrite", "critical", "destructive", 213 "overwrites system configuration file"), 214 (r'\bmkfs\b', 215 "format_filesystem", "critical", "destructive", 216 "formats a filesystem"), 217 (r'\bdd\s+.*if=.*of=/dev/', 218 "disk_overwrite", "critical", "destructive", 219 "raw disk write operation"), 220 (r'shutil\.rmtree\s*\(\s*[\"\'/]', 221 "python_rmtree", "high", "destructive", 222 "Python rmtree on absolute or root-relative path"), 223 (r'truncate\s+-s\s*0\s+/', 224 "truncate_system", "critical", "destructive", 225 "truncates system file to zero bytes"), 226 227 # ── Persistence ── 228 (r'\bcrontab\b', 229 "persistence_cron", "medium", "persistence", 230 "modifies cron jobs"), 231 (r'\.(bashrc|zshrc|profile|bash_profile|bash_login|zprofile|zlogin)\b', 232 "shell_rc_mod", "medium", "persistence", 233 "references shell startup file"), 234 (r'authorized_keys', 235 "ssh_backdoor", "critical", "persistence", 236 "modifies SSH authorized keys"), 237 (r'ssh-keygen', 238 "ssh_keygen", "medium", "persistence", 239 "generates SSH keys"), 240 (r'systemd.*\.service|systemctl\s+(enable|start)', 241 "systemd_service", "medium", "persistence", 242 "references or enables systemd service"), 243 (r'/etc/init\.d/', 244 "init_script", "medium", "persistence", 245 "references init.d startup script"), 246 (r'launchctl\s+load|LaunchAgents|LaunchDaemons', 247 "macos_launchd", "medium", "persistence", 248 "macOS launch agent/daemon persistence"), 249 (r'/etc/sudoers|visudo', 250 "sudoers_mod", "critical", "persistence", 251 "modifies sudoers (privilege escalation)"), 252 (r'git\s+config\s+--global\s+', 253 "git_config_global", "medium", "persistence", 254 "modifies global git configuration"), 255 256 # ── Network: reverse shells and tunnels ── 257 (r'\bnc\s+-[lp]|ncat\s+-[lp]|\bsocat\b', 258 "reverse_shell", "critical", "network", 259 "potential reverse shell listener"), 260 (r'\bngrok\b|\blocaltunnel\b|\bserveo\b|\bcloudflared\b', 261 "tunnel_service", "high", "network", 262 "uses tunneling service for external access"), 263 (r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{2,5}', 264 "hardcoded_ip_port", "medium", "network", 265 "hardcoded IP address with port"), 266 (r'0\.0\.0\.0:\d+|INADDR_ANY', 267 "bind_all_interfaces", "high", "network", 268 "binds to all network interfaces"), 269 (r'/bin/(ba)?sh\s+-i\s+.*>/dev/tcp/', 270 "bash_reverse_shell", "critical", "network", 271 "bash interactive reverse shell via /dev/tcp"), 272 (r'python[23]?\s+-c\s+["\']import\s+socket', 273 "python_socket_oneliner", "critical", "network", 274 "Python one-liner socket connection (likely reverse shell)"), 275 (r'socket\.connect\s*\(\s*\(', 276 "python_socket_connect", "high", "network", 277 "Python socket connect to arbitrary host"), 278 (r'webhook\.site|requestbin\.com|pipedream\.net|hookbin\.com', 279 "exfil_service", "high", "network", 280 "references known data exfiltration/webhook testing service"), 281 (r'pastebin\.com|hastebin\.com|ghostbin\.', 282 "paste_service", "medium", "network", 283 "references paste service (possible data staging)"), 284 285 # ── Obfuscation: encoding and eval ── 286 (r'base64\s+(-d|--decode)\s*\|', 287 "base64_decode_pipe", "high", "obfuscation", 288 "base64 decodes and pipes to execution"), 289 (r'\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}', 290 "hex_encoded_string", "medium", "obfuscation", 291 "hex-encoded string (possible obfuscation)"), 292 (r'\beval\s*\(\s*["\']', 293 "eval_string", "high", "obfuscation", 294 "eval() with string argument"), 295 (r'\bexec\s*\(\s*["\']', 296 "exec_string", "high", "obfuscation", 297 "exec() with string argument"), 298 (r'echo\s+[^\n]*\|\s*(bash|sh|python|perl|ruby|node)', 299 "echo_pipe_exec", "critical", "obfuscation", 300 "echo piped to interpreter for execution"), 301 (r'compile\s*\(\s*[^\)]+,\s*["\'].*["\']\s*,\s*["\']exec["\']\s*\)', 302 "python_compile_exec", "high", "obfuscation", 303 "Python compile() with exec mode"), 304 (r'getattr\s*\(\s*__builtins__', 305 "python_getattr_builtins", "high", "obfuscation", 306 "dynamic access to Python builtins (evasion technique)"), 307 (r'__import__\s*\(\s*["\']os["\']\s*\)', 308 "python_import_os", "high", "obfuscation", 309 "dynamic import of os module"), 310 (r'codecs\.decode\s*\(\s*["\']', 311 "python_codecs_decode", "medium", "obfuscation", 312 "codecs.decode (possible ROT13 or encoding obfuscation)"), 313 (r'String\.fromCharCode|charCodeAt', 314 "js_char_code", "medium", "obfuscation", 315 "JavaScript character code construction (possible obfuscation)"), 316 (r'atob\s*\(|btoa\s*\(', 317 "js_base64", "medium", "obfuscation", 318 "JavaScript base64 encode/decode"), 319 (r'\[::-1\]', 320 "string_reversal", "low", "obfuscation", 321 "string reversal (possible obfuscated payload)"), 322 (r'chr\s*\(\s*\d+\s*\)\s*\+\s*chr\s*\(\s*\d+', 323 "chr_building", "high", "obfuscation", 324 "building string from chr() calls (obfuscation)"), 325 (r'\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}', 326 "unicode_escape_chain", "medium", "obfuscation", 327 "chain of unicode escapes (possible obfuscation)"), 328 329 # ── Process execution in scripts ── 330 (r'subprocess\.(run|call|Popen|check_output)\s*\(', 331 "python_subprocess", "medium", "execution", 332 "Python subprocess execution"), 333 (r'os\.system\s*\(', 334 "python_os_system", "high", "execution", 335 "os.system() — unguarded shell execution"), 336 (r'os\.popen\s*\(', 337 "python_os_popen", "high", "execution", 338 "os.popen() — shell pipe execution"), 339 (r'child_process\.(exec|spawn|fork)\s*\(', 340 "node_child_process", "high", "execution", 341 "Node.js child_process execution"), 342 (r'Runtime\.getRuntime\(\)\.exec\(', 343 "java_runtime_exec", "high", "execution", 344 "Java Runtime.exec() — shell execution"), 345 (r'`[^`]*\$\([^)]+\)[^`]*`', 346 "backtick_subshell", "medium", "execution", 347 "backtick string with command substitution"), 348 349 # ── Path traversal ── 350 (r'\.\./\.\./\.\.', 351 "path_traversal_deep", "high", "traversal", 352 "deep relative path traversal (3+ levels up)"), 353 (r'\.\./\.\.', 354 "path_traversal", "medium", "traversal", 355 "relative path traversal (2+ levels up)"), 356 (r'/etc/passwd|/etc/shadow', 357 "system_passwd_access", "critical", "traversal", 358 "references system password files"), 359 (r'/proc/self|/proc/\d+/', 360 "proc_access", "high", "traversal", 361 "references /proc filesystem (process introspection)"), 362 (r'/dev/shm/', 363 "dev_shm", "medium", "traversal", 364 "references shared memory (common staging area)"), 365 366 # ── Crypto mining ── 367 (r'xmrig|stratum\+tcp|monero|coinhive|cryptonight', 368 "crypto_mining", "critical", "mining", 369 "cryptocurrency mining reference"), 370 (r'hashrate|nonce.*difficulty', 371 "mining_indicators", "medium", "mining", 372 "possible cryptocurrency mining indicators"), 373 374 # ── Supply chain: curl/wget pipe to shell ── 375 (r'curl\s+[^\n]*\|\s*(ba)?sh', 376 "curl_pipe_shell", "critical", "supply_chain", 377 "curl piped to shell (download-and-execute)"), 378 (r'wget\s+[^\n]*-O\s*-\s*\|\s*(ba)?sh', 379 "wget_pipe_shell", "critical", "supply_chain", 380 "wget piped to shell (download-and-execute)"), 381 (r'curl\s+[^\n]*\|\s*python', 382 "curl_pipe_python", "critical", "supply_chain", 383 "curl piped to Python interpreter"), 384 385 # ── Supply chain: unpinned/deferred dependencies ── 386 (r'#\s*///\s*script.*dependencies', 387 "pep723_inline_deps", "medium", "supply_chain", 388 "PEP 723 inline script metadata with dependencies (verify pinning)"), 389 (r'pip\s+install\s+(?!-r\s)(?!.*==)', 390 "unpinned_pip_install", "medium", "supply_chain", 391 "pip install without version pinning"), 392 (r'npm\s+install\s+(?!.*@\d)', 393 "unpinned_npm_install", "medium", "supply_chain", 394 "npm install without version pinning"), 395 (r'uv\s+run\s+', 396 "uv_run", "medium", "supply_chain", 397 "uv run (may auto-install unpinned dependencies)"), 398 399 # ── Supply chain: remote resource fetching ── 400 (r'(curl|wget|httpx?\.get|requests\.get|fetch)\s*[\(]?\s*["\']https?://', 401 "remote_fetch", "medium", "supply_chain", 402 "fetches remote resource at runtime"), 403 (r'git\s+clone\s+', 404 "git_clone", "medium", "supply_chain", 405 "clones a git repository at runtime"), 406 (r'docker\s+pull\s+', 407 "docker_pull", "medium", "supply_chain", 408 "pulls a Docker image at runtime"), 409 410 # ── Privilege escalation ── 411 (r'^allowed-tools\s*:', 412 "allowed_tools_field", "high", "privilege_escalation", 413 "skill declares allowed-tools (pre-approves tool access)"), 414 (r'\bsudo\b', 415 "sudo_usage", "high", "privilege_escalation", 416 "uses sudo (privilege escalation)"), 417 (r'setuid|setgid|cap_setuid', 418 "setuid_setgid", "critical", "privilege_escalation", 419 "setuid/setgid (privilege escalation mechanism)"), 420 (r'NOPASSWD', 421 "nopasswd_sudo", "critical", "privilege_escalation", 422 "NOPASSWD sudoers entry (passwordless privilege escalation)"), 423 (r'chmod\s+[u+]?s', 424 "suid_bit", "critical", "privilege_escalation", 425 "sets SUID/SGID bit on a file"), 426 427 # ── Agent config persistence ── 428 (r'AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules', 429 "agent_config_mod", "critical", "persistence", 430 "references agent config files (could persist malicious instructions across sessions)"), 431 (r'\.hermes/config\.yaml|\.hermes/SOUL\.md', 432 "hermes_config_mod", "critical", "persistence", 433 "references Hermes configuration files directly"), 434 (r'\.claude/settings|\.codex/config', 435 "other_agent_config", "high", "persistence", 436 "references other agent configuration files"), 437 438 # ── Hardcoded secrets (credentials embedded in the skill itself) ── 439 (r'(?:api[_-]?key|token|secret|password)\s*[=:]\s*["\'][A-Za-z0-9+/=_-]{20,}', 440 "hardcoded_secret", "critical", "credential_exposure", 441 "possible hardcoded API key, token, or secret"), 442 (r'-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----', 443 "embedded_private_key", "critical", "credential_exposure", 444 "embedded private key"), 445 (r'ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{80,}', 446 "github_token_leaked", "critical", "credential_exposure", 447 "GitHub personal access token in skill content"), 448 (r'sk-[A-Za-z0-9]{20,}', 449 "openai_key_leaked", "critical", "credential_exposure", 450 "possible OpenAI API key in skill content"), 451 (r'sk-ant-[A-Za-z0-9_-]{90,}', 452 "anthropic_key_leaked", "critical", "credential_exposure", 453 "possible Anthropic API key in skill content"), 454 (r'AKIA[0-9A-Z]{16}', 455 "aws_access_key_leaked", "critical", "credential_exposure", 456 "AWS access key ID in skill content"), 457 458 # ── Additional prompt injection: jailbreak patterns ── 459 (r'\bDAN\s+mode\b|Do\s+Anything\s+Now', 460 "jailbreak_dan", "critical", "injection", 461 "DAN (Do Anything Now) jailbreak attempt"), 462 (r'\bdeveloper\s+mode\b.*\benabled?\b', 463 "jailbreak_dev_mode", "critical", "injection", 464 "developer mode jailbreak attempt"), 465 (r'hypothetical\s+scenario.*(?:ignore|bypass|override)', 466 "hypothetical_bypass", "high", "injection", 467 "hypothetical scenario used to bypass restrictions"), 468 (r'for\s+educational\s+purposes?\s+only', 469 "educational_pretext", "medium", "injection", 470 "educational pretext often used to justify harmful content"), 471 (r'(respond|answer|reply)\s+without\s+(?:\w+\s+)*(restrictions|limitations|filters|safety)', 472 "remove_filters", "critical", "injection", 473 "instructs agent to respond without safety filters"), 474 (r'you\s+have\s+been\s+(?:\w+\s+)*(updated|upgraded|patched)\s+to', 475 "fake_update", "high", "injection", 476 "fake update/patch announcement (social engineering)"), 477 (r'new\s+policy|updated\s+guidelines|revised\s+instructions', 478 "fake_policy", "medium", "injection", 479 "claims new policy/guidelines (may be social engineering)"), 480 481 # ── Context window exfiltration ── 482 (r'(include|output|print|send|share)\s+(?:\w+\s+)*(conversation|chat\s+history|previous\s+messages|context)', 483 "context_exfil", "high", "exfiltration", 484 "instructs agent to output/share conversation history"), 485 (r'(send|post|upload|transmit)\s+.*\s+(to|at)\s+https?://', 486 "send_to_url", "high", "exfiltration", 487 "instructs agent to send data to a URL"), 488 ] 489 490 # Structural limits for skill directories 491 MAX_FILE_COUNT = 50 # skills shouldn't have 50+ files 492 MAX_TOTAL_SIZE_KB = 1024 # 1MB total is suspicious for a skill 493 MAX_SINGLE_FILE_KB = 256 # individual file > 256KB is suspicious 494 495 # File extensions to scan (text files only — skip binary) 496 SCANNABLE_EXTENSIONS = { 497 '.md', '.txt', '.py', '.sh', '.bash', '.js', '.ts', '.rb', 498 '.yaml', '.yml', '.json', '.toml', '.cfg', '.ini', '.conf', 499 '.html', '.css', '.xml', '.tex', '.r', '.jl', '.pl', '.php', 500 } 501 502 # Known binary extensions that should NOT be in a skill 503 SUSPICIOUS_BINARY_EXTENSIONS = { 504 '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.com', 505 '.msi', '.dmg', '.app', '.deb', '.rpm', 506 } 507 508 # Zero-width and invisible unicode characters used for injection 509 INVISIBLE_CHARS = { 510 '\u200b', # zero-width space 511 '\u200c', # zero-width non-joiner 512 '\u200d', # zero-width joiner 513 '\u2060', # word joiner 514 '\u2062', # invisible times 515 '\u2063', # invisible separator 516 '\u2064', # invisible plus 517 '\ufeff', # zero-width no-break space (BOM) 518 '\u202a', # left-to-right embedding 519 '\u202b', # right-to-left embedding 520 '\u202c', # pop directional formatting 521 '\u202d', # left-to-right override 522 '\u202e', # right-to-left override 523 '\u2066', # left-to-right isolate 524 '\u2067', # right-to-left isolate 525 '\u2068', # first strong isolate 526 '\u2069', # pop directional isolate 527 } 528 529 530 # --------------------------------------------------------------------------- 531 # Scanning functions 532 # --------------------------------------------------------------------------- 533 534 def scan_file(file_path: Path, rel_path: str = "") -> List[Finding]: 535 """ 536 Scan a single file for threat patterns and invisible unicode characters. 537 538 Args: 539 file_path: Absolute path to the file 540 rel_path: Relative path for display (defaults to file_path.name) 541 542 Returns: 543 List of findings (deduplicated per pattern per line) 544 """ 545 if not rel_path: 546 rel_path = file_path.name 547 548 if file_path.suffix.lower() not in SCANNABLE_EXTENSIONS and file_path.name != "SKILL.md": 549 return [] 550 551 try: 552 content = file_path.read_text(encoding='utf-8') 553 except (UnicodeDecodeError, OSError): 554 return [] 555 556 findings = [] 557 lines = content.split('\n') 558 seen = set() # (pattern_id, line_number) for deduplication 559 560 # Regex pattern matching 561 for pattern, pid, severity, category, description in THREAT_PATTERNS: 562 for i, line in enumerate(lines, start=1): 563 if (pid, i) in seen: 564 continue 565 if re.search(pattern, line, re.IGNORECASE): 566 seen.add((pid, i)) 567 matched_text = line.strip() 568 if len(matched_text) > 120: 569 matched_text = matched_text[:117] + "..." 570 findings.append(Finding( 571 pattern_id=pid, 572 severity=severity, 573 category=category, 574 file=rel_path, 575 line=i, 576 match=matched_text, 577 description=description, 578 )) 579 580 # Invisible unicode character detection 581 for i, line in enumerate(lines, start=1): 582 for char in INVISIBLE_CHARS: 583 if char in line: 584 char_name = _unicode_char_name(char) 585 findings.append(Finding( 586 pattern_id="invisible_unicode", 587 severity="high", 588 category="injection", 589 file=rel_path, 590 line=i, 591 match=f"U+{ord(char):04X} ({char_name})", 592 description=f"invisible unicode character {char_name} (possible text hiding/injection)", 593 )) 594 break # one finding per line for invisible chars 595 596 return findings 597 598 599 def scan_skill(skill_path: Path, source: str = "community") -> ScanResult: 600 """ 601 Scan all files in a skill directory for security threats. 602 603 Performs: 604 1. Structural checks (file count, total size, binary files, symlinks) 605 2. Regex pattern matching on all text files 606 3. Invisible unicode character detection 607 608 Args: 609 skill_path: Path to the skill directory (must contain SKILL.md) 610 source: Source identifier for trust level resolution (e.g. "openai/skills") 611 612 Returns: 613 ScanResult with verdict, findings, and trust metadata 614 """ 615 skill_name = skill_path.name 616 trust_level = _resolve_trust_level(source) 617 618 all_findings: List[Finding] = [] 619 620 if skill_path.is_dir(): 621 # Structural checks first 622 all_findings.extend(_check_structure(skill_path)) 623 624 # Pattern scanning on each file 625 for f in skill_path.rglob("*"): 626 if f.is_file(): 627 rel = str(f.relative_to(skill_path)) 628 all_findings.extend(scan_file(f, rel)) 629 elif skill_path.is_file(): 630 all_findings.extend(scan_file(skill_path, skill_path.name)) 631 632 verdict = _determine_verdict(all_findings) 633 summary = _build_summary(skill_name, source, trust_level, verdict, all_findings) 634 635 return ScanResult( 636 skill_name=skill_name, 637 source=source, 638 trust_level=trust_level, 639 verdict=verdict, 640 findings=all_findings, 641 scanned_at=datetime.now(timezone.utc).isoformat(), 642 summary=summary, 643 ) 644 645 646 def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]: 647 """ 648 Determine whether a skill should be installed based on scan result and trust. 649 650 Args: 651 result: Scan result from scan_skill() 652 force: If True, override blocked policy decisions for this scan result 653 654 Returns: 655 (allowed, reason) tuple 656 """ 657 policy = INSTALL_POLICY.get(result.trust_level, INSTALL_POLICY["community"]) 658 vi = VERDICT_INDEX.get(result.verdict, 2) 659 decision = policy[vi] 660 661 if decision == "allow": 662 return True, f"Allowed ({result.trust_level} source, {result.verdict} verdict)" 663 664 if force: 665 return True, ( 666 f"Force-installed despite {result.verdict} verdict " 667 f"({len(result.findings)} findings)" 668 ) 669 670 if decision == "ask": 671 # Return None to signal "needs user confirmation" 672 return None, ( 673 f"Requires confirmation ({result.trust_level} source + {result.verdict} verdict, " 674 f"{len(result.findings)} findings)" 675 ) 676 677 return False, ( 678 f"Blocked ({result.trust_level} source + {result.verdict} verdict, " 679 f"{len(result.findings)} findings). Use --force to override." 680 ) 681 682 683 def format_scan_report(result: ScanResult) -> str: 684 """ 685 Format a scan result as a human-readable report string. 686 687 Returns a compact multi-line report suitable for CLI or chat display. 688 """ 689 lines = [] 690 691 verdict_display = result.verdict.upper() 692 lines.append(f"Scan: {result.skill_name} ({result.source}/{result.trust_level}) Verdict: {verdict_display}") 693 694 if result.findings: 695 # Group and sort: critical first, then high, medium, low 696 severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3} 697 sorted_findings = sorted(result.findings, key=lambda f: severity_order.get(f.severity, 4)) 698 699 for f in sorted_findings: 700 sev = f.severity.upper().ljust(8) 701 cat = f.category.ljust(14) 702 loc = f"{f.file}:{f.line}".ljust(30) 703 lines.append(f" {sev} {cat} {loc} \"{f.match[:60]}\"") 704 705 lines.append("") 706 707 allowed, reason = should_allow_install(result) 708 if allowed is True: 709 status = "ALLOWED" 710 elif allowed is None: 711 status = "NEEDS CONFIRMATION" 712 else: 713 status = "BLOCKED" 714 lines.append(f"Decision: {status} — {reason}") 715 716 return "\n".join(lines) 717 718 719 def content_hash(skill_path: Path) -> str: 720 """Compute a SHA-256 hash of all files in a skill directory for integrity tracking.""" 721 h = hashlib.sha256() 722 if skill_path.is_dir(): 723 for f in sorted(skill_path.rglob("*")): 724 if f.is_file(): 725 try: 726 h.update(f.read_bytes()) 727 except OSError: 728 continue 729 elif skill_path.is_file(): 730 h.update(skill_path.read_bytes()) 731 return f"sha256:{h.hexdigest()[:16]}" 732 733 734 # --------------------------------------------------------------------------- 735 # Structural checks 736 # --------------------------------------------------------------------------- 737 738 def _check_structure(skill_dir: Path) -> List[Finding]: 739 """ 740 Check the skill directory for structural anomalies: 741 - Too many files 742 - Suspiciously large total size 743 - Binary/executable files that shouldn't be in a skill 744 - Symlinks pointing outside the skill directory 745 - Individual files that are too large 746 """ 747 findings = [] 748 file_count = 0 749 total_size = 0 750 751 for f in skill_dir.rglob("*"): 752 if not f.is_file() and not f.is_symlink(): 753 continue 754 755 rel = str(f.relative_to(skill_dir)) 756 file_count += 1 757 758 # Symlink check — must resolve within the skill directory 759 if f.is_symlink(): 760 try: 761 resolved = f.resolve() 762 if not resolved.is_relative_to(skill_dir.resolve()): 763 findings.append(Finding( 764 pattern_id="symlink_escape", 765 severity="critical", 766 category="traversal", 767 file=rel, 768 line=0, 769 match=f"symlink -> {resolved}", 770 description="symlink points outside the skill directory", 771 )) 772 except OSError: 773 findings.append(Finding( 774 pattern_id="broken_symlink", 775 severity="medium", 776 category="traversal", 777 file=rel, 778 line=0, 779 match="broken symlink", 780 description="broken or circular symlink", 781 )) 782 continue 783 784 # Size tracking 785 try: 786 size = f.stat().st_size 787 total_size += size 788 except OSError: 789 continue 790 791 # Single file too large 792 if size > MAX_SINGLE_FILE_KB * 1024: 793 findings.append(Finding( 794 pattern_id="oversized_file", 795 severity="medium", 796 category="structural", 797 file=rel, 798 line=0, 799 match=f"{size // 1024}KB", 800 description=f"file is {size // 1024}KB (limit: {MAX_SINGLE_FILE_KB}KB)", 801 )) 802 803 # Binary/executable files 804 ext = f.suffix.lower() 805 if ext in SUSPICIOUS_BINARY_EXTENSIONS: 806 findings.append(Finding( 807 pattern_id="binary_file", 808 severity="critical", 809 category="structural", 810 file=rel, 811 line=0, 812 match=f"binary: {ext}", 813 description=f"binary/executable file ({ext}) should not be in a skill", 814 )) 815 816 # Executable permission on non-script files 817 if ext not in ('.sh', '.bash', '.py', '.rb', '.pl') and f.stat().st_mode & 0o111: 818 findings.append(Finding( 819 pattern_id="unexpected_executable", 820 severity="medium", 821 category="structural", 822 file=rel, 823 line=0, 824 match="executable bit set", 825 description="file has executable permission but is not a recognized script type", 826 )) 827 828 # File count limit 829 if file_count > MAX_FILE_COUNT: 830 findings.append(Finding( 831 pattern_id="too_many_files", 832 severity="medium", 833 category="structural", 834 file="(directory)", 835 line=0, 836 match=f"{file_count} files", 837 description=f"skill has {file_count} files (limit: {MAX_FILE_COUNT})", 838 )) 839 840 # Total size limit 841 if total_size > MAX_TOTAL_SIZE_KB * 1024: 842 findings.append(Finding( 843 pattern_id="oversized_skill", 844 severity="high", 845 category="structural", 846 file="(directory)", 847 line=0, 848 match=f"{total_size // 1024}KB total", 849 description=f"skill is {total_size // 1024}KB total (limit: {MAX_TOTAL_SIZE_KB}KB)", 850 )) 851 852 return findings 853 854 855 def _unicode_char_name(char: str) -> str: 856 """Get a readable name for an invisible unicode character.""" 857 names = { 858 '\u200b': "zero-width space", 859 '\u200c': "zero-width non-joiner", 860 '\u200d': "zero-width joiner", 861 '\u2060': "word joiner", 862 '\u2062': "invisible times", 863 '\u2063': "invisible separator", 864 '\u2064': "invisible plus", 865 '\ufeff': "BOM/zero-width no-break space", 866 '\u202a': "LTR embedding", 867 '\u202b': "RTL embedding", 868 '\u202c': "pop directional", 869 '\u202d': "LTR override", 870 '\u202e': "RTL override", 871 '\u2066': "LTR isolate", 872 '\u2067': "RTL isolate", 873 '\u2068': "first strong isolate", 874 '\u2069': "pop directional isolate", 875 } 876 return names.get(char, f"U+{ord(char):04X}") 877 878 879 880 # --------------------------------------------------------------------------- 881 # Internal helpers 882 # --------------------------------------------------------------------------- 883 884 def _resolve_trust_level(source: str) -> str: 885 """Map a source identifier to a trust level.""" 886 prefix_aliases = ( 887 "skills-sh/", 888 "skills.sh/", 889 "skils-sh/", 890 "skils.sh/", 891 ) 892 normalized_source = source 893 for prefix in prefix_aliases: 894 if normalized_source.startswith(prefix): 895 normalized_source = normalized_source[len(prefix):] 896 break 897 898 # Agent-created skills get their own permissive trust level 899 if normalized_source == "agent-created": 900 return "agent-created" 901 # Official optional skills shipped with the repo 902 if normalized_source.startswith("official/") or normalized_source == "official": 903 return "builtin" 904 # Check if source matches any trusted repo 905 for trusted in TRUSTED_REPOS: 906 if normalized_source.startswith(trusted) or normalized_source == trusted: 907 return "trusted" 908 return "community" 909 910 911 def _determine_verdict(findings: List[Finding]) -> str: 912 """Determine the overall verdict from a list of findings.""" 913 if not findings: 914 return "safe" 915 916 has_critical = any(f.severity == "critical" for f in findings) 917 has_high = any(f.severity == "high" for f in findings) 918 919 if has_critical: 920 return "dangerous" 921 if has_high: 922 return "caution" 923 return "caution" 924 925 926 def _build_summary(name: str, source: str, trust: str, verdict: str, findings: List[Finding]) -> str: 927 """Build a one-line summary of the scan result.""" 928 if not findings: 929 return f"{name}: clean scan, no threats detected" 930 931 categories = set(f.category for f in findings) 932 return f"{name}: {verdict} — {len(findings)} finding(s) in {', '.join(sorted(categories))}"