/ tools / skills_guard.py
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))}"