Files
aiturk-hermes-ide/tools/skills_guard.py

1361 lines
57 KiB
Python

#!/usr/bin/env python3
"""
Skills Guard — Security scanner for externally-sourced skills.
Every skill downloaded from a registry passes through this scanner before
installation. It uses regex-based static analysis to detect known-bad patterns
(data exfiltration, prompt injection, destructive commands, persistence, etc.)
and a trust-aware install policy that determines whether a skill is allowed
based on both the scan verdict and the source's trust level.
Trust levels:
- builtin: Ships with Hermes. Never scanned, always trusted.
- trusted: openai/skills and anthropics/skills only. Caution verdicts allowed.
- community: Everything else. Any findings = blocked unless --force.
Usage:
from tools.skills_guard import scan_skill, should_allow_install, format_scan_report
result = scan_skill(Path("skills/.hub/quarantine/some-skill"), source="community")
allowed, reason = should_allow_install(result)
if not allowed:
print(format_scan_report(result))
Known limitation — programmatic writes (out of scope for this static pass):
the agent-config persistence tiers score shell write mechanics
(">>" redirection, "sed -i") and imperative modification prose only.
Language write APIs in bundled scripts — Python open(..., 'w'/'a'),
pathlib.Path.write_text(), os.replace(), shutil.copy*, and Node
fs.writeFileSync()/appendFile() — aimed at agent-config files surface
only the low-severity *_ref finding, never a scored persistence tier.
Static regexes cannot reliably tie such a call to the config-file
destination (paths may be built dynamically) without executing the
skill, so language-API persistence is left to runtime gates (install
confirmation, sandboxing). If coverage is added later, it belongs as a
fourth "mechanical" tier next to agent_config_mod_shell, requiring the
config-file name as a literal argument at the call site.
"""
import re
import fnmatch
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Tuple
SCANNER_VERSION = "skills-guard-v2"
# ---------------------------------------------------------------------------
# Hardcoded trust configuration
# ---------------------------------------------------------------------------
TRUSTED_REPOS = {
"openai/skills",
"anthropics/skills",
"huggingface/skills",
# NVIDIA-verified skills: each entry ships a signed `skill.oms.sig`
# and a governance `skill-card.md` (sync pipeline drops anything
# missing the signature or card). Catalog details:
# https://github.com/NVIDIA/skills
"NVIDIA/skills",
}
INSTALL_POLICY = {
# safe caution dangerous
"builtin": ("allow", "allow", "allow"),
"trusted": ("allow", "allow", "block"),
"community": ("allow", "block", "block"),
# Agent-created: "ask" on dangerous surfaces as an error to the agent,
# which can retry without the flagged content. This gate only runs when
# skills.guard_agent_created is enabled (off by default) — see
# tools/skill_manager_tool.py::_guard_agent_created_enabled.
"agent-created": ("allow", "allow", "ask"),
}
VERDICT_INDEX = {"safe": 0, "caution": 1, "dangerous": 2}
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class Finding:
pattern_id: str
severity: str # "critical" | "high" | "medium" | "low"
category: str # "exfiltration" | "injection" | "destructive" | "persistence" | "network" | "obfuscation"
file: str
line: int
match: str
description: str
@dataclass
class ScanResult:
skill_name: str
source: str
trust_level: str # "builtin" | "trusted" | "community"
verdict: str # "safe" | "caution" | "dangerous"
findings: List[Finding] = field(default_factory=list)
scanned_at: str = ""
summary: str = ""
scan_provenance: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Threat patterns — (regex, pattern_id, severity, category, description)
# ---------------------------------------------------------------------------
# Action verbs that signal file-modification intent. Used by the agent-config
# persistence patterns: a verb within the same line as (and shortly before) an
# agent config filename is scored as modification; a bare mention is not.
MODIFY_VERB_RE = (
r'(?:\bwrit(?:e|es|ing)\b|\bwritten\b|\bedit(?:s|ed|ing)?\b'
r'|\bmodif(?:y|ies|ied|ying|ication)s?\b|\bupdat(?:e|es|ed|ing)\b'
r'|\bappend(?:s|ed|ing)?\b|\bprepend(?:s|ed|ing)?\b'
r'|\binject(?:s|ed|ing)?\b|\boverwrit(?:e|es|ing)\b|\boverwritten\b'
r'|\breplac(?:e|es|ed|ing)\b|\balter(?:s|ed|ing)?\b|\badd(?:s|ed|ing)\b)'
)
# Config-file groups shared by the agent-config persistence tiers below.
_AGENT_CONFIG_FILES = r'(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)'
_HERMES_CONFIG_FILES = r'\.hermes/(?:config\.yaml|SOUL\.md)'
# Path prefixes (real files are e.g. .claude/settings.json), so consume any
# trailing filename characters rather than requiring a clean end-of-word.
_OTHER_AGENT_CONFIG_FILES = r'\.(?:claude/settings|codex/config)[\w.]*'
def _shell_write_re(file_alt: str) -> str:
"""Regex for a mechanical shell write into *file_alt*.
Covers redirection (``>``/``>>``), in-place ``sed -i``, ``tee`` (with the
target as its immediate argument, so a markdown table cell like
``| tee output | AGENTS.md |`` does not match), and ``cp``/``mv`` with the
config file in destination position (a preceding source argument is
required, so ``cp AGENTS.md backup/`` — a read — does not match; a
trailing extension like ``AGENTS.md.bak`` is not the config file).
A single ``>`` must be preceded by a word/quote/paren character so that
markdown blockquotes (``> text``) and arrows (``-> file``) do not match.
"""
return (
rf'(?:>>|[\w"\'`)\]]\s*>)\s*[~\w./-]*{file_alt}(?!\.?\w)'
rf'|\bsed\b[^\n]*\s(?:-[A-Za-z]*i[A-Za-z]*|--in-place)\b[^\n]*{file_alt}(?!\.?\w)'
rf'|\btee\s+(?:-a\s+)?[~\w./"\'-]*{file_alt}(?!\.?\w)'
rf'|\b(?:cp|mv)\s+[^\s|;&]+\s+[^\n|;&]{{0,40}}?{file_alt}(?!\.?\w)'
)
def _prose_modify_re(file_alt: str) -> str:
"""Regex for prose instructing modification of *file_alt*.
Two shapes: an imperative-position verb (start of line / bullet item),
or a mid-line verb strengthened by an explicit directive marker
("you must", "please", "make sure to"). Descriptive mid-line prose
("skills that edit AGENTS.md") matches neither. The verb→file gap
forbids commas so enumerations ("Write or refactor skills, AGENTS.md,
CLAUDE.md") — a doc listing its subject matter — do not match.
"""
return (
rf'^\s*(?:[-*+]\s+|\d+[.)]\s+)?{MODIFY_VERB_RE}[^\n,]{{0,80}}?{file_alt}\b'
rf'|(?:\byou\s+(?:must|should|need\s+to)\s+|\bplease\s+'
rf'|\bmake\s+sure\s+(?:to\s+|you\s+)|\bbe\s+sure\s+to\s+)'
rf'{MODIFY_VERB_RE}[^\n,]{{0,80}}?{file_alt}\b'
)
def _content_contract_re(file_alt: str) -> str:
"""Regex for "<file> should contain/include ..." content-contract prose.
Ambiguous shape: authoring guides teach "Every AGENTS.md should contain
the project purpose" while an attack writes "AGENTS.md should contain
the bypass instructions". Not separable statically, so this tier is
scored high (caution → user confirmation), never critical.
"""
return (
rf'{file_alt}\b[^\n]{{0,40}}?\b(?:should|must|needs?\s+to)\s+'
rf'(?:contain|say|include|have|list)\b'
)
THREAT_PATTERNS = [
# ── Exfiltration: shell commands leaking secrets ──
# All five env_exfil_* patterns share a loopback exemption: a request
# whose same-line literal destination is scheme-anchored loopback
# (http(s)://localhost, 127.0.0.1, [::1]) cannot move data off the
# machine, so a secret-shaped query param on it is a local session
# token, not exfiltration (e.g. impeccable's live-mode
# `fetch('http://localhost:'+PORT+'/status?token='+TOKEN)`). The
# exemption requires the scheme immediately before the loopback host —
# `evil.com/?u=localhost` does not qualify. A hostile skill that hides
# its real destination behind a variable never matched these same-line
# literal patterns in the first place.
(r'curl\s+(?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b',
"env_exfil_curl", "critical", "exfiltration",
"curl command interpolating secret environment variable"),
(r'wget\s+(?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b',
"env_exfil_wget", "critical", "exfiltration",
"wget command interpolating secret environment variable"),
(r'fetch\s*\((?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD)S?\b',
"env_exfil_fetch", "critical", "exfiltration",
"fetch() call interpolating secret environment variable"),
(r'httpx?\.(get|post|put|patch)\s*\((?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*(KEY|TOKEN|SECRET|PASSWORD)',
"env_exfil_httpx", "critical", "exfiltration",
"HTTP library call with secret variable"),
(r'requests\.(get|post|put|patch)\s*\((?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*(KEY|TOKEN|SECRET|PASSWORD)',
"env_exfil_requests", "critical", "exfiltration",
"requests library call with secret variable"),
# ── Exfiltration: reading credential stores ──
(r'base64[^\n]*env',
"encoded_exfil", "high", "exfiltration",
"base64 encoding combined with environment access"),
(r'\$HOME/\.ssh|\~/\.ssh',
"ssh_dir_access", "high", "exfiltration",
"references user SSH directory"),
(r'\$HOME/\.aws|\~/\.aws',
"aws_dir_access", "high", "exfiltration",
"references user AWS credentials directory"),
(r'\$HOME/\.gnupg|\~/\.gnupg',
"gpg_dir_access", "high", "exfiltration",
"references user GPG keyring"),
(r'\$HOME/\.kube|\~/\.kube',
"kube_dir_access", "high", "exfiltration",
"references Kubernetes config directory"),
(r'\$HOME/\.docker|\~/\.docker',
"docker_dir_access", "high", "exfiltration",
"references Docker config (may contain registry creds)"),
(r'\$HOME/\.hermes/\.env|\~/\.hermes/\.env',
"hermes_env_access", "critical", "exfiltration",
"directly references Hermes secrets file"),
# Match `cat <secrets-file>` (reading credentials) but NOT `cat > <file>`
# or `cat >> <file>`, which are output redirections that WRITE a file
# (e.g. a setup doc telling the user to write their own keys into their
# own local `.env` via a heredoc). Writing your own config in is the
# opposite of exfiltrating secrets out.
(r'cat\s+(?!>)[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)',
"read_secrets_file", "critical", "exfiltration",
"reads known secrets file"),
# ── Exfiltration: programmatic env access ──
(r'printenv|env\s*\|',
"dump_all_env", "high", "exfiltration",
"dumps all environment variables"),
# `os.environ` bare access (dict dump / iteration) is suspicious, but the
# common `os.environ.get("SOME_CONFIG")` form is just a config read and is
# the OPPOSITE of exfiltration (it reads a local var, sends nothing). The
# ^[^#\n]* prevents matching when a '#' comment appears anywhere before
# os.environ on the line — handles both full-line comments and inline
# comments like `x = 1 # os.environ`. The docstring pre-filter in
# scan_file() skips lines inside triple-quoted strings entirely.
# ANY `.get("<name>")` form is exempt here — non-secret names are plain
# config reads, and secret-shaped names are scored (medium) by the
# dedicated python_environ_get_secret pattern below; without the blanket
# exemption the high severity here would swamp that intended medium.
(r'^[^#\n]*os\.environ\b(?!\s*\.get\s*\()',
"python_os_environ", "high", "exfiltration",
"accesses os.environ outside comments/docstrings (potential env dump)"),
(r'os\.environ\s*\.get\s*\(\s*["\'][^"\']*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)',
"python_environ_get_secret", "medium", "exfiltration",
"reads secret via os.environ.get() (normal API-key access; informational)"),
(r'os\.getenv\s*\(\s*[^\)]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)',
"python_getenv_secret", "medium", "exfiltration",
"reads secret via os.getenv() (normal API-key access; informational)"),
(r'process\.env\[',
"node_process_env", "high", "exfiltration",
"accesses process.env (Node.js environment)"),
# Case-sensitive ENV (Ruby constant) — the (?-i:) prevents matching
# Python lowercase `env[...]` dict accesses under IGNORECASE.
(r'(?-i:ENV)\[.*(?:KEY|TOKEN|SECRET|PASSWORD)',
"ruby_env_secret", "critical", "exfiltration",
"reads secret via Ruby ENV[]"),
# ── Exfiltration: DNS and staging ──
# Do not match flag names such as llama.cpp `--host 127.0.0.1 --port $PORT`.
(r'(?<![-/])\b(dig|nslookup|host)\s+[^\n]*\$',
"dns_exfil", "critical", "exfiltration",
"DNS lookup with variable interpolation (possible DNS exfiltration)"),
(r'>\s*/tmp/[^\s]*\s*&&\s*(curl|wget|nc|python)',
"tmp_staging", "critical", "exfiltration",
"writes to /tmp then exfiltrates"),
# ── Exfiltration: markdown/link based ──
(r'!\[.*\]\(https?://[^\)]*\$\{?',
"md_image_exfil", "high", "exfiltration",
"markdown image URL with variable interpolation (image-based exfil)"),
(r'\[.*\]\(https?://[^\)]*\$\{?',
"md_link_exfil", "high", "exfiltration",
"markdown link with variable interpolation"),
# ── Prompt injection ──
(r'ignore\s+(?:\w+\s+)*(previous|all|above|prior)\s+instructions',
"prompt_injection_ignore", "critical", "injection",
"prompt injection: ignore previous instructions"),
(r'you\s+are\s+(?:\w+\s+)*now\s+',
"role_hijack", "high", "injection",
"attempts to override the agent's role"),
# Only flag when the instruction is about concealing information, not
# ordinary UX guidance ("don't tell the user X unless Y confirms").
# The negative lookahead excludes patterns common in UX instructions
# like "unless", "except", "until", "confirm", "diagnose", "verify".
(r'do\s+not\s+(?:\w+\s+)*tell\s+(?:\w+\s+)*the\s+user(?!.*\b(?:unless|except|until|confirm|diagnose|verify|check)\b)',
"deception_hide", "high", "injection",
"instructs agent to hide information from user"),
(r'system\s+(?:\w+\s+)*prompt\s+(?:\w+\s+)*override',
"sys_prompt_override", "critical", "injection",
"attempts to override the system prompt"),
(r'pretend\s+(?:\w+\s+)*(you\s+are|to\s+be)\s+',
"role_pretend", "high", "injection",
"attempts to make the agent assume a different identity"),
(r'disregard\s+(?:\w+\s+)*(your|all|any)\s+(?:\w+\s+)*(instructions|rules|guidelines)',
"disregard_rules", "critical", "injection",
"instructs agent to disregard its rules"),
(r'output\s+(?:\w+\s+)*(system|initial)\s+prompt',
"leak_system_prompt", "high", "injection",
"attempts to extract the system prompt"),
(r'(when|if)\s+no\s*one\s+is\s+(watching|looking)',
"conditional_deception", "high", "injection",
"conditional instruction to behave differently when unobserved"),
(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)',
"bypass_restrictions", "critical", "injection",
"instructs agent to act without restrictions"),
(r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)',
"translate_execute", "critical", "injection",
"translate-then-execute evasion technique"),
(r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->',
"html_comment_injection", "high", "injection",
"hidden instructions in HTML comments"),
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none',
"hidden_div", "high", "injection",
"hidden HTML div (invisible instructions)"),
# ── Destructive operations ──
(r'rm\s+-rf\s+/',
"destructive_root_rm", "critical", "destructive",
"recursive delete from root"),
(r'rm\s+(-[^\s]*)?r.*\$HOME|\brmdir\s+.*\$HOME',
"destructive_home_rm", "critical", "destructive",
"recursive delete targeting home directory"),
(r'chmod\s+777',
"insecure_perms", "medium", "destructive",
"sets world-writable permissions"),
(r'>\s*/etc/',
"system_overwrite", "critical", "destructive",
"overwrites system configuration file"),
(r'\bmkfs\b',
"format_filesystem", "critical", "destructive",
"formats a filesystem"),
(r'\bdd\s+.*if=.*of=/dev/',
"disk_overwrite", "critical", "destructive",
"raw disk write operation"),
(r'shutil\.rmtree\s*\(\s*[\"\'/]',
"python_rmtree", "high", "destructive",
"Python rmtree on absolute or root-relative path"),
(r'truncate\s+-s\s*0\s+/',
"truncate_system", "critical", "destructive",
"truncates system file to zero bytes"),
# ── Persistence ──
(r'\bcrontab\b',
"persistence_cron", "medium", "persistence",
"modifies cron jobs"),
(r'\.(bashrc|zshrc|profile|bash_profile|bash_login|zprofile|zlogin)\b',
"shell_rc_mod", "medium", "persistence",
"references shell startup file"),
(r'authorized_keys',
"ssh_backdoor", "critical", "persistence",
"modifies SSH authorized keys"),
(r'ssh-keygen',
"ssh_keygen", "medium", "persistence",
"generates SSH keys"),
(r'systemd.*\.service|systemctl\s+(enable|start)',
"systemd_service", "medium", "persistence",
"references or enables systemd service"),
(r'/etc/init\.d/',
"init_script", "medium", "persistence",
"references init.d startup script"),
(r'launchctl\s+load|LaunchAgents|LaunchDaemons',
"macos_launchd", "medium", "persistence",
"macOS launch agent/daemon persistence"),
(r'/etc/sudoers|visudo',
"sudoers_mod", "critical", "persistence",
"modifies sudoers (privilege escalation)"),
(r'git\s+config\s+--global\s+',
"git_config_global", "medium", "persistence",
"modifies global git configuration"),
# ── Network: reverse shells and tunnels ──
(r'\bnc\s+-[lp]|ncat\s+-[lp]|\bsocat\b',
"reverse_shell", "critical", "network",
"potential reverse shell listener"),
(r'\bngrok\b|\blocaltunnel\b|\bserveo\b|\bcloudflared\b',
"tunnel_service", "high", "network",
"uses tunneling service for external access"),
(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{2,5}',
"hardcoded_ip_port", "medium", "network",
"hardcoded IP address with port"),
(r'0\.0\.0\.0:\d+|INADDR_ANY',
"bind_all_interfaces", "high", "network",
"binds to all network interfaces"),
(r'/bin/(ba)?sh\s+-i\s+.*>/dev/tcp/',
"bash_reverse_shell", "critical", "network",
"bash interactive reverse shell via /dev/tcp"),
(r'python[23]?\s+-c\s+["\']import\s+socket',
"python_socket_oneliner", "critical", "network",
"Python one-liner socket connection (likely reverse shell)"),
(r'socket\.connect\s*\(\s*\(',
"python_socket_connect", "high", "network",
"Python socket connect to arbitrary host"),
(r'webhook\.site|requestbin\.com|pipedream\.net|hookbin\.com',
"exfil_service", "high", "network",
"references known data exfiltration/webhook testing service"),
(r'pastebin\.com|hastebin\.com|ghostbin\.',
"paste_service", "medium", "network",
"references paste service (possible data staging)"),
# ── Obfuscation: encoding and eval ──
(r'base64\s+(-d|--decode)\s*\|',
"base64_decode_pipe", "high", "obfuscation",
"base64 decodes and pipes to execution"),
(r'\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}',
"hex_encoded_string", "medium", "obfuscation",
"hex-encoded string (possible obfuscation)"),
(r'\beval\s*\(\s*["\']',
"eval_string", "high", "obfuscation",
"eval() with string argument"),
(r'\bexec\s*\(\s*["\']',
"exec_string", "high", "obfuscation",
"exec() with string argument"),
(r'echo\s+[^\n]*\|\s*(bash|sh|python|perl|ruby|node)',
"echo_pipe_exec", "critical", "obfuscation",
"echo piped to interpreter for execution"),
(r'compile\s*\(\s*[^\)]+,\s*["\'].*["\']\s*,\s*["\']exec["\']\s*\)',
"python_compile_exec", "high", "obfuscation",
"Python compile() with exec mode"),
(r'getattr\s*\(\s*__builtins__',
"python_getattr_builtins", "high", "obfuscation",
"dynamic access to Python builtins (evasion technique)"),
(r'__import__\s*\(\s*["\']os["\']\s*\)',
"python_import_os", "high", "obfuscation",
"dynamic import of os module"),
(r'codecs\.decode\s*\(\s*["\']',
"python_codecs_decode", "medium", "obfuscation",
"codecs.decode (possible ROT13 or encoding obfuscation)"),
(r'String\.fromCharCode|charCodeAt',
"js_char_code", "medium", "obfuscation",
"JavaScript character code construction (possible obfuscation)"),
(r'atob\s*\(|btoa\s*\(',
"js_base64", "medium", "obfuscation",
"JavaScript base64 encode/decode"),
(r'\[::-1\]',
"string_reversal", "low", "obfuscation",
"string reversal (possible obfuscated payload)"),
(r'chr\s*\(\s*\d+\s*\)\s*\+\s*chr\s*\(\s*\d+',
"chr_building", "high", "obfuscation",
"building string from chr() calls (obfuscation)"),
(r'\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}',
"unicode_escape_chain", "medium", "obfuscation",
"chain of unicode escapes (possible obfuscation)"),
# ── Process execution in scripts ──
(r'subprocess\.(run|call|Popen|check_output)\s*\(',
"python_subprocess", "medium", "execution",
"Python subprocess execution"),
(r'os\.system\s*\(',
"python_os_system", "high", "execution",
"os.system() — unguarded shell execution"),
(r'os\.popen\s*\(',
"python_os_popen", "high", "execution",
"os.popen() — shell pipe execution"),
(r'child_process\.(exec|spawn|fork)\s*\(',
"node_child_process", "high", "execution",
"Node.js child_process execution"),
(r'Runtime\.getRuntime\(\)\.exec\(',
"java_runtime_exec", "high", "execution",
"Java Runtime.exec() — shell execution"),
(r'`[^`]*\$\([^)]+\)[^`]*`',
"backtick_subshell", "medium", "execution",
"backtick string with command substitution"),
# ── Path traversal ──
(r'\.\./\.\./\.\.',
"path_traversal_deep", "high", "traversal",
"deep relative path traversal (3+ levels up)"),
(r'\.\./\.\.',
"path_traversal", "medium", "traversal",
"relative path traversal (2+ levels up)"),
(r'/etc/passwd|/etc/shadow',
"system_passwd_access", "critical", "traversal",
"references system password files"),
(r'/proc/self|/proc/\d+/',
"proc_access", "high", "traversal",
"references /proc filesystem (process introspection)"),
(r'/dev/shm/',
"dev_shm", "medium", "traversal",
"references shared memory (common staging area)"),
# ── Crypto mining ──
(r'xmrig|stratum\+tcp|monero|coinhive|cryptonight',
"crypto_mining", "critical", "mining",
"cryptocurrency mining reference"),
(r'hashrate|nonce.*difficulty',
"mining_indicators", "medium", "mining",
"possible cryptocurrency mining indicators"),
# ── Supply chain: curl/wget pipe to shell ──
(r'curl\s+[^\n]*\|\s*(ba)?sh',
"curl_pipe_shell", "critical", "supply_chain",
"curl piped to shell (download-and-execute)"),
(r'wget\s+[^\n]*-O\s*-\s*\|\s*(ba)?sh',
"wget_pipe_shell", "critical", "supply_chain",
"wget piped to shell (download-and-execute)"),
(r'curl\s+[^\n]*\|\s*python',
"curl_pipe_python", "critical", "supply_chain",
"curl piped to Python interpreter"),
# ── Supply chain: unpinned/deferred dependencies ──
(r'#\s*///\s*script.*dependencies',
"pep723_inline_deps", "medium", "supply_chain",
"PEP 723 inline script metadata with dependencies (verify pinning)"),
(r'pip\s+install\s+(?!-r\s)(?!.*==)',
"unpinned_pip_install", "medium", "supply_chain",
"pip install without version pinning"),
(r'npm\s+install\s+(?!.*@\d)',
"unpinned_npm_install", "medium", "supply_chain",
"npm install without version pinning"),
(r'uv\s+run\s+',
"uv_run", "medium", "supply_chain",
"uv run (may auto-install unpinned dependencies)"),
# ── Supply chain: remote resource fetching ──
(r'(curl|wget|httpx?\.get|requests\.get|fetch)\s*[\(]?\s*["\']https?://',
"remote_fetch", "medium", "supply_chain",
"fetches remote resource at runtime"),
(r'git\s+clone\s+',
"git_clone", "medium", "supply_chain",
"clones a git repository at runtime"),
(r'docker\s+pull\s+',
"docker_pull", "medium", "supply_chain",
"pulls a Docker image at runtime"),
# ── Privilege escalation ──
# `allowed-tools:` is REQUIRED SKILL.md frontmatter per the agent-skill
# spec — every compliant skill declares it, so it cannot be a threat
# signal on its own. Keep it as an informational (low) finding for
# auditability; it no longer drives the verdict.
(r'^allowed-tools\s*:',
"allowed_tools_field", "low", "privilege_escalation",
"skill declares allowed-tools (standard frontmatter; informational)"),
(r'\bsudo\b',
"sudo_usage", "high", "privilege_escalation",
"uses sudo (privilege escalation)"),
(r'setuid|setgid|cap_setuid',
"setuid_setgid", "critical", "privilege_escalation",
"setuid/setgid (privilege escalation mechanism)"),
(r'NOPASSWD',
"nopasswd_sudo", "critical", "privilege_escalation",
"NOPASSWD sudoers entry (passwordless privilege escalation)"),
(r'chmod\s+[u+]?s',
"suid_bit", "critical", "privilege_escalation",
"sets SUID/SGID bit on a file"),
# ── Agent config persistence ──
# Mere mentions of agent config files are NOT threats by themselves —
# legitimate meta-skills discuss them constantly (authoring guides,
# setup docs, cross-references to other skills). Flagging any mention
# as critical produced permanent false-positive blocks for popular
# community skills (#92021). Tiers instead:
# * Mechanical persistence (shell redirection, sed -i, tee, cp/mv
# into the file) is critical — an unambiguous write path.
# * Prose modification intent — an imperative-position verb or an
# explicit directive ("you must edit ...") aimed at the file.
# For AGENT config files (AGENTS.md/CLAUDE.md/...) this is critical:
# that sentence shape is exactly how persistence attacks instruct
# the agent, and project-skill quarantine only acts on "dangerous".
# For Hermes/other config files it is high (caution) — legitimate
# setup docs routinely instruct users to edit config.yaml.
# * Bare references are informational (low) for auditability.
(_prose_modify_re(_AGENT_CONFIG_FILES),
"agent_config_mod", "critical", "persistence",
"instructs modification of agent config files (could persist instructions across sessions)"),
(_shell_write_re(_AGENT_CONFIG_FILES),
"agent_config_mod_shell", "critical", "persistence",
"shell write (redirect/sed -i/tee/cp/mv) targeting agent config files (persistence mechanism)"),
(_content_contract_re(_AGENT_CONFIG_FILES),
"agent_config_contract", "high", "persistence",
"dictates agent config file contents (verify intent — authoring guides use this shape too)"),
(r'AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules',
"agent_config_ref", "low", "persistence",
"references agent config files (informational; only modification intent is scored)"),
(_prose_modify_re(_HERMES_CONFIG_FILES),
"hermes_config_mod", "high", "persistence",
"modification language aimed at Hermes configuration files (verify intent)"),
(_shell_write_re(_HERMES_CONFIG_FILES),
"hermes_config_mod_shell", "critical", "persistence",
"shell write (redirect/sed -i/tee/cp/mv) targeting Hermes configuration files"),
(r'\.hermes/config\.yaml|\.hermes/SOUL\.md',
"hermes_config_ref", "low", "persistence",
"references Hermes configuration files (informational; only modification intent is scored)"),
(_prose_modify_re(_OTHER_AGENT_CONFIG_FILES),
"other_agent_config_mod", "high", "persistence",
"modifies other agents' configuration files"),
(_shell_write_re(_OTHER_AGENT_CONFIG_FILES),
"other_agent_config_mod_shell", "critical", "persistence",
"shell write (redirect/sed -i/tee/cp/mv) targeting other agents' configuration files"),
(r'\.claude/settings|\.codex/config',
"other_agent_config_ref", "low", "persistence",
"references other agent configuration files (informational; only modification intent is scored)"),
# ── Hardcoded secrets (credentials embedded in the skill itself) ──
(r'(?:api[_-]?key|token|secret|password)\s*[=:]\s*["\'][A-Za-z0-9+/=_-]{20,}',
"hardcoded_secret", "critical", "credential_exposure",
"possible hardcoded API key, token, or secret"),
(r'-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----',
"embedded_private_key", "critical", "credential_exposure",
"embedded private key"),
(r'ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{80,}',
"github_token_leaked", "critical", "credential_exposure",
"GitHub personal access token in skill content"),
(r'sk-[A-Za-z0-9]{20,}',
"openai_key_leaked", "critical", "credential_exposure",
"possible OpenAI API key in skill content"),
(r'sk-ant-[A-Za-z0-9_-]{90,}',
"anthropic_key_leaked", "critical", "credential_exposure",
"possible Anthropic API key in skill content"),
(r'AKIA[0-9A-Z]{16}',
"aws_access_key_leaked", "critical", "credential_exposure",
"AWS access key ID in skill content"),
(r'glpat-[A-Za-z0-9_\-]{20,}',
"gitlab_token_leaked", "critical", "credential_exposure",
"GitLab personal access token in skill content"),
# ── Additional prompt injection: jailbreak patterns ──
(r'\bDAN\s+mode\b|Do\s+Anything\s+Now',
"jailbreak_dan", "critical", "injection",
"DAN (Do Anything Now) jailbreak attempt"),
(r'\bdeveloper\s+mode\b.*\benabled?\b',
"jailbreak_dev_mode", "critical", "injection",
"developer mode jailbreak attempt"),
(r'hypothetical\s+scenario.*(?:ignore|bypass|override)',
"hypothetical_bypass", "high", "injection",
"hypothetical scenario used to bypass restrictions"),
(r'for\s+educational\s+purposes?\s+only',
"educational_pretext", "medium", "injection",
"educational pretext often used to justify harmful content"),
(r'(respond|answer|reply)\s+without\s+(?:\w+\s+)*(restrictions|limitations|filters|safety)',
"remove_filters", "critical", "injection",
"instructs agent to respond without safety filters"),
(r'you\s+have\s+been\s+(?:\w+\s+)*(updated|upgraded|patched)\s+to',
"fake_update", "high", "injection",
"fake update/patch announcement (social engineering)"),
(r'new\s+(?:\w+\s+)*policy|updated\s+(?:\w+\s+)*guidelines|revised\s+(?:\w+\s+)*instructions',
"fake_policy", "medium", "injection",
"claims new policy/guidelines (may be social engineering)"),
# ── Context window exfiltration ──
(r'(include|output|print|send|share)\s+(?:\w+\s+)*(conversation|chat\s+history|previous\s+messages|context)',
"context_exfil", "high", "exfiltration",
"instructs agent to output/share conversation history"),
(r'(send|post|upload|transmit)\s+.*\s+(to|at)\s+https?://',
"send_to_url", "high", "exfiltration",
"instructs agent to send data to a URL"),
]
_COMPILED_THREAT_PATTERNS = [
(re.compile(pattern, re.IGNORECASE), pid, severity, category, description)
for pattern, pid, severity, category, description in THREAT_PATTERNS
]
# Structural limits for skill directories
MAX_FILE_COUNT = 50 # skills shouldn't have 50+ files
MAX_TOTAL_SIZE_KB = 5120 # 5MB — large skills are informational only, not blocking
MAX_SINGLE_FILE_KB = 256 # individual file > 256KB is suspicious
# File extensions to scan (text files only — skip binary)
SCANNABLE_EXTENSIONS = {
'.md', '.txt', '.py', '.sh', '.bash', '.js', '.ts', '.rb',
'.yaml', '.yml', '.json', '.toml', '.cfg', '.ini', '.conf',
'.html', '.css', '.xml', '.tex', '.r', '.jl', '.pl', '.php',
}
# Known binary extensions that should NOT be in a skill
SUSPICIOUS_BINARY_EXTENSIONS = {
'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.com',
'.msi', '.dmg', '.app', '.deb', '.rpm',
}
# Zero-width and invisible unicode characters used for injection
INVISIBLE_CHARS = {
'\u200b', # zero-width space
'\u200c', # zero-width non-joiner
'\u200d', # zero-width joiner
'\u2060', # word joiner
'\u2062', # invisible times
'\u2063', # invisible separator
'\u2064', # invisible plus
'\ufeff', # zero-width no-break space (BOM)
'\u202a', # left-to-right embedding
'\u202b', # right-to-left embedding
'\u202c', # pop directional formatting
'\u202d', # left-to-right override
'\u202e', # right-to-left override
'\u2066', # left-to-right isolate
'\u2067', # right-to-left isolate
'\u2068', # first strong isolate
'\u2069', # pop directional isolate
}
def _compute_docstring_lines(lines: list) -> set:
"""Return a set of 1-indexed line numbers inside triple-quoted strings.
Uses a simple state machine: toggles ``in_docstring`` each time a line
contains an odd number of ``\"\"\"`` or triple-single-quote markers. Lines
that are *themselves* part of a docstring (opening line, interior lines,
and closing line) are all included in the returned set.
Single-line docstrings (e.g. ``x = \"\"\" ... \"\"\"``) where both the
opening and closing markers appear on the same line are also flagged,
since ``os.environ`` in such a context is not real exfiltration.
This is a heuristic -- it does not handle
``'\\\"\"\"' # triple quote inside a string literal``
or similar edge cases -- but it catches the common skill-content patterns
(docstrings, multiline comments containing prose samples) that trigger
false-positive ``python_os_environ`` matches.
"""
doc_lines: set = set()
in_docstring = False
for i, line in enumerate(lines):
was_in = in_docstring
has_marker = False
for marker in ('"""', "'''"):
count = line.count(marker)
if count > 0:
has_marker = True
if count % 2 == 1:
in_docstring = not in_docstring
# Include line if we were already in a docstring, just entered one,
# or this is a self-contained single-line docstring (e.g. """foo""")
if was_in or in_docstring or (has_marker and not was_in and not in_docstring):
doc_lines.add(i + 1)
return doc_lines
# ---------------------------------------------------------------------------
# Scanning functions
# ---------------------------------------------------------------------------
def scan_file(file_path: Path, rel_path: str = "") -> List[Finding]:
"""
Scan a single file for threat patterns and invisible unicode characters.
Args:
file_path: Absolute path to the file
rel_path: Relative path for display (defaults to file_path.name)
Returns:
List of findings (deduplicated per pattern per line)
"""
if not rel_path:
rel_path = file_path.name
if file_path.suffix.lower() not in SCANNABLE_EXTENSIONS and file_path.name != "SKILL.md":
return []
try:
content = file_path.read_text(encoding='utf-8')
except (UnicodeDecodeError, OSError):
return []
findings = []
lines = content.split('\n')
seen = set() # (pattern_id, line_number) for deduplication
# Pre-compute line numbers inside triple-quoted strings (docstrings)
# so code patterns like python_os_environ don't fire on prose.
docstring_lines = _compute_docstring_lines(lines)
# Regex pattern matching
for pattern, pid, severity, category, description in _COMPILED_THREAT_PATTERNS:
for i, line in enumerate(lines, start=1):
if (pid, i) in seen:
continue
if i in docstring_lines:
continue
if pattern.search(line):
seen.add((pid, i))
matched_text = line.strip()
if len(matched_text) > 120:
matched_text = matched_text[:117] + "..."
findings.append(Finding(
pattern_id=pid,
severity=severity,
category=category,
file=rel_path,
line=i,
match=matched_text,
description=description,
))
# Invisible unicode character detection
for i, line in enumerate(lines, start=1):
for char in INVISIBLE_CHARS:
if char in line:
char_name = _unicode_char_name(char)
findings.append(Finding(
pattern_id="invisible_unicode",
severity="high",
category="injection",
file=rel_path,
line=i,
match=f"U+{ord(char):04X} ({char_name})",
description=f"invisible unicode character {char_name} (possible text hiding/injection)",
))
break # one finding per line for invisible chars
return findings
def scan_skill(skill_path: Path, source: str = "community") -> ScanResult:
"""
Scan all files in a skill directory for security threats.
Performs:
1. Structural checks (file count, total size, binary files, symlinks)
2. Regex pattern matching on all text files
3. Invisible unicode character detection
A skill may ship a `.skillignore` (or `.clawhubignore`) file with
gitignore-style patterns. Matching paths are excluded from BOTH the
structural checks and the pattern scan, so development/docs artifacts
that are not part of the installed skill (e.g. `SKILL-original.md`,
`docs/plans/`, `release-notes.md`) don't trip findings. The ignore
file itself is always excluded. Patterns cannot un-ignore the
skill's own `SKILL.md`, which is always scanned.
Args:
skill_path: Path to the skill directory (must contain SKILL.md)
source: Source identifier for trust level resolution (e.g. "openai/skills")
Returns:
ScanResult with verdict, findings, and trust metadata
"""
skill_name = skill_path.name
trust_level = _resolve_trust_level(source)
all_findings: List[Finding] = []
if skill_path.is_dir():
ignore = _load_skill_ignore(skill_path)
# Structural checks first (honoring the ignore list)
all_findings.extend(_check_structure(skill_path, ignore=ignore))
# Pattern scanning on each file
for f in skill_path.rglob("*"):
if f.is_file():
rel = str(f.relative_to(skill_path))
if ignore(rel):
continue
all_findings.extend(scan_file(f, rel))
elif skill_path.is_file():
all_findings.extend(scan_file(skill_path, skill_path.name))
verdict = _determine_verdict(all_findings)
summary = _build_summary(skill_name, source, trust_level, verdict, all_findings)
return ScanResult(
skill_name=skill_name,
source=source,
trust_level=trust_level,
verdict=verdict,
findings=all_findings,
scanned_at=datetime.now(timezone.utc).isoformat(),
summary=summary,
)
def _content_digest(skill_path: Path) -> str:
"""Canonical SHA-256 over relative paths and exact file bytes.
Files are keyed and ORDERED by their POSIX relative path string,
case-sensitively. Ordering by ``sorted(rglob(...))`` diverged from the
bundle side on Windows: Path comparison is case-insensitive there
(normcase), while ``bundle_content_hash`` sorts plain strings — the
same skill hashed to different digests and every installed skill
reported ``update_available`` forever (#62310). Sorting the rel-posix
strings makes the digest OS-independent and byte-symmetric with
``tools.skills_hub.bundle_content_hash``.
"""
h = hashlib.sha256()
if skill_path.is_dir():
entries = sorted(
(file_path.relative_to(skill_path).as_posix(), file_path)
for file_path in skill_path.rglob("*")
if file_path.is_file()
)
for rel, file_path in entries:
h.update(rel.encode("utf-8") + b"\x00")
h.update(file_path.read_bytes())
else:
h.update(skill_path.read_bytes())
return h.hexdigest()
def full_content_hash(skill_path: Path) -> str:
"""Full canonical digest used to bind scanner attestations."""
return f"sha256:{_content_digest(skill_path)}"
def _finding_dict(finding: Finding) -> dict:
return {key: getattr(finding, key) for key in (
"pattern_id", "severity", "category", "file", "line", "match", "description"
)}
def scan_skill_cached(
skill_path: Path,
source: str = "community",
*,
source_url: str = "",
cache_dir: Path | None = None,
) -> Tuple[ScanResult, dict]:
"""Return a scan plus attestation, caching only exact current content."""
bundle_hash = full_content_hash(skill_path)
cache_root = cache_dir or skill_path.parent / ".scan-cache"
source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16]
cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}-{source_identity}.json"
try:
cached = json.loads(cache_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
cached = None
if (isinstance(cached, dict)
and cached.get("bundle_hash") == bundle_hash
and cached.get("scanner_version") == SCANNER_VERSION
and cached.get("source") == source
and cached.get("source_url") == source_url):
result = ScanResult(
skill_name=skill_path.name, source=source,
trust_level=cached["trust_level"], verdict=cached["verdict"],
findings=[Finding(**item) for item in cached.get("findings", [])],
scanned_at=cached["scanned_at"], summary=cached.get("summary", ""),
)
provenance = dict(cached)
provenance["fresh"] = False
result.scan_provenance = provenance
return result, provenance
result = scan_skill(skill_path, source=source)
findings = [_finding_dict(item) for item in result.findings]
provenance = {
"source": source, "source_url": source_url, "bundle_hash": bundle_hash,
"scanner_version": SCANNER_VERSION, "verdict": result.verdict,
"trust_level": result.trust_level, "findings": findings,
"rules": sorted({item["pattern_id"] for item in findings}),
"scanned_at": result.scanned_at, "summary": result.summary, "fresh": True,
}
try:
cache_root.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
except OSError:
pass
result.scan_provenance = provenance
return result, provenance
def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]:
"""
Determine whether a skill should be installed based on scan result and trust.
Args:
result: Scan result from scan_skill()
force: If True, override blocked policy decisions for this scan result
Returns:
(allowed, reason) tuple
"""
policy = INSTALL_POLICY.get(result.trust_level, INSTALL_POLICY["community"])
vi = VERDICT_INDEX.get(result.verdict, 2)
decision = policy[vi]
if decision == "allow":
return True, f"Allowed ({result.trust_level} source, {result.verdict} verdict)"
if force and not (result.verdict == "dangerous" and result.trust_level in ("community", "trusted")):
return True, (
f"Force-installed despite {result.verdict} verdict "
f"({len(result.findings)} findings)"
)
if decision == "ask":
# Return None to signal "needs user confirmation"
return None, (
f"Requires confirmation ({result.trust_level} source + {result.verdict} verdict, "
f"{len(result.findings)} findings)"
)
# Dangerous verdicts cannot be overridden by --force (community/trusted);
# other blocks can.
if result.verdict == "dangerous" and result.trust_level in ("community", "trusted"):
return False, (
f"Blocked ({result.trust_level} source + dangerous verdict, "
f"{len(result.findings)} findings). --force does not override a dangerous verdict."
)
return False, (
f"Blocked ({result.trust_level} source + {result.verdict} verdict, "
f"{len(result.findings)} findings). Use --force to override."
)
def format_scan_report(result: ScanResult) -> str:
"""
Format a scan result as a human-readable report string.
Returns a compact multi-line report suitable for CLI or chat display.
"""
lines = []
verdict_display = result.verdict.upper()
lines.append(f"Scan: {result.skill_name} ({result.source}/{result.trust_level}) Verdict: {verdict_display}")
if result.findings:
# Group and sort: critical first, then high, medium, low
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
sorted_findings = sorted(result.findings, key=lambda f: severity_order.get(f.severity, 4))
for f in sorted_findings:
sev = f.severity.upper().ljust(8)
cat = f.category.ljust(14)
loc = f"{f.file}:{f.line}".ljust(30)
lines.append(f" {sev} {cat} {loc} \"{f.match[:60]}\"")
lines.append("")
allowed, reason = should_allow_install(result)
if allowed is True:
status = "ALLOWED"
elif allowed is None:
status = "NEEDS CONFIRMATION"
else:
status = "BLOCKED"
lines.append(f"Decision: {status}{reason}")
return "\n".join(lines)
def content_hash(skill_path: Path) -> str:
"""Compute a SHA-256 hash of all files in a skill directory for integrity tracking.
File paths (relative to ``skill_path``) are mixed into the hash alongside
file contents so that swapping the contents of two files in a skill
changes the hash. This must stay symmetric with
``tools.skills_hub.bundle_content_hash`` — both functions need to
produce the same digest for the same skill (one operates on disk,
one on an in-memory bundle), so any change to the hash shape MUST
land in both places at once.
"""
return f"sha256:{_content_digest(skill_path)[:16]}"
# ---------------------------------------------------------------------------
# Structural checks
# ---------------------------------------------------------------------------
def _check_structure(skill_dir: Path, ignore=None) -> List[Finding]:
"""
Check the skill directory for structural anomalies:
- Too many files
- Suspiciously large total size
- Binary/executable files that shouldn't be in a skill
- Symlinks pointing outside the skill directory
- Individual files that are too large
Args:
skill_dir: Path to the skill directory.
ignore: Optional callable taking a relative posix path and returning
True if the path should be excluded (e.g. from `.skillignore`).
Ignored files are not counted toward the file count, total size,
or any structural finding.
"""
if ignore is None:
ignore = lambda _rel: False # noqa: E731
findings = []
file_count = 0
total_size = 0
for f in skill_dir.rglob("*"):
if not f.is_file() and not f.is_symlink():
continue
rel = str(f.relative_to(skill_dir))
if ignore(rel):
continue
file_count += 1
# Symlink check — must resolve within the skill directory
if f.is_symlink():
try:
resolved = f.resolve()
if not resolved.is_relative_to(skill_dir.resolve()):
findings.append(Finding(
pattern_id="symlink_escape",
severity="critical",
category="traversal",
file=rel,
line=0,
match=f"symlink -> {resolved}",
description="symlink points outside the skill directory",
))
except OSError:
findings.append(Finding(
pattern_id="broken_symlink",
severity="medium",
category="traversal",
file=rel,
line=0,
match="broken symlink",
description="broken or circular symlink",
))
continue
# Size tracking
try:
size = f.stat().st_size
total_size += size
except OSError:
continue
# Single file too large
if size > MAX_SINGLE_FILE_KB * 1024:
findings.append(Finding(
pattern_id="oversized_file",
severity="medium",
category="structural",
file=rel,
line=0,
match=f"{size // 1024}KB",
description=f"file is {size // 1024}KB (limit: {MAX_SINGLE_FILE_KB}KB)",
))
# Binary/executable files
ext = f.suffix.lower()
if ext in SUSPICIOUS_BINARY_EXTENSIONS:
findings.append(Finding(
pattern_id="binary_file",
severity="critical",
category="structural",
file=rel,
line=0,
match=f"binary: {ext}",
description=f"binary/executable file ({ext}) should not be in a skill",
))
# Executable permission on non-script files
if ext not in {'.sh', '.bash', '.py', '.rb', '.pl'} and f.stat().st_mode & 0o111:
findings.append(Finding(
pattern_id="unexpected_executable",
severity="medium",
category="structural",
file=rel,
line=0,
match="executable bit set",
description="file has executable permission but is not a recognized script type",
))
# File count limit
if file_count > MAX_FILE_COUNT:
findings.append(Finding(
pattern_id="too_many_files",
severity="medium",
category="structural",
file="(directory)",
line=0,
match=f"{file_count} files",
description=f"skill has {file_count} files (limit: {MAX_FILE_COUNT})",
))
# Total size limit — informational only (low severity, non-verdict-gating).
# Large skills are legitimate for feature-rich capabilities.
if total_size > MAX_TOTAL_SIZE_KB * 1024:
findings.append(Finding(
pattern_id="oversized_skill",
severity="low",
category="structural",
file="(directory)",
line=0,
match=f"{total_size // 1024}KB total",
description=f"skill is {total_size // 1024}KB total (limit: {MAX_TOTAL_SIZE_KB}KB)",
))
return findings
def _unicode_char_name(char: str) -> str:
"""Get a readable name for an invisible unicode character."""
names = {
'\u200b': "zero-width space",
'\u200c': "zero-width non-joiner",
'\u200d': "zero-width joiner",
'\u2060': "word joiner",
'\u2062': "invisible times",
'\u2063': "invisible separator",
'\u2064': "invisible plus",
'\ufeff': "BOM/zero-width no-break space",
'\u202a': "LTR embedding",
'\u202b': "RTL embedding",
'\u202c': "pop directional",
'\u202d': "LTR override",
'\u202e': "RTL override",
'\u2066': "LTR isolate",
'\u2067': "RTL isolate",
'\u2068': "first strong isolate",
'\u2069': "pop directional isolate",
}
return names.get(char, f"U+{ord(char):04X}")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
# Ignore-file names a skill may ship to exclude dev/docs artifacts from the
# scan. `.skillignore` is the Hermes-native name; `.clawhubignore` is honored
# for compatibility with skills published through ClawHub.
_SKILL_IGNORE_FILENAMES = (".skillignore", ".clawhubignore")
# Paths that are NEVER scanned regardless of ignore patterns, and SKILL.md
# which can never be un-scanned via the ignore file.
_ALWAYS_IGNORED_NAMES = set(_SKILL_IGNORE_FILENAMES)
_NEVER_IGNORABLE = {"SKILL.md"}
def _load_skill_ignore(skill_dir: Path):
"""Build a matcher from a skill's `.skillignore` / `.clawhubignore`.
Returns a callable ``ignore(rel_posix_path) -> bool``. The matcher
supports gitignore-style basics: blank lines and ``#`` comments are
skipped, a trailing ``/`` marks a directory (matches that dir and
everything under it), and ``*``/``?`` globs are honored via fnmatch on
both the full relative path and each path segment. A leading ``/``
anchors a pattern to the skill root. The ignore files themselves are
always excluded; ``SKILL.md`` can never be excluded.
"""
patterns: List[str] = []
for name in _SKILL_IGNORE_FILENAMES:
ig = skill_dir / name
try:
if ig.is_file():
for raw in ig.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
patterns.append(line)
except (UnicodeDecodeError, OSError):
continue
def ignore(rel: str) -> bool:
rel_posix = Path(rel).as_posix()
base = rel_posix.split("/")[-1]
if base in _NEVER_IGNORABLE:
return False
if base in _ALWAYS_IGNORED_NAMES:
return True
for pat in patterns:
anchored = pat.startswith("/")
p = pat.lstrip("/")
is_dir = p.endswith("/")
p = p.rstrip("/")
if not p:
continue
if is_dir:
# Directory pattern: match the dir itself or anything under it.
if rel_posix == p or rel_posix.startswith(p + "/"):
return True
if not anchored and ("/" + rel_posix + "/").find("/" + p + "/") != -1:
return True
continue
# File/glob pattern.
if fnmatch.fnmatch(rel_posix, p):
return True
if not anchored:
# Unanchored: also match the basename and any path segment.
if fnmatch.fnmatch(base, p):
return True
if "/" not in p and any(
fnmatch.fnmatch(seg, p) for seg in rel_posix.split("/")
):
return True
# Match a prefix directory component (e.g. `docs` ignores
# `docs/plans/x.md`).
if rel_posix.startswith(p + "/"):
return True
return False
return ignore
def _resolve_trust_level(source: str) -> str:
"""Map a source identifier to a trust level."""
prefix_aliases = (
"skills-sh/",
"skills.sh/",
"skils-sh/",
"skils.sh/",
)
normalized_source = source
for prefix in prefix_aliases:
if normalized_source.startswith(prefix):
normalized_source = normalized_source[len(prefix):]
break
# Agent-created skills get their own permissive trust level
if normalized_source == "agent-created":
return "agent-created"
# Official optional skills must be identified by source provenance, not by
# user-controlled GitHub identifiers such as "official/<repo>".
if normalized_source == "official":
return "builtin"
# Check if source matches any trusted repo exactly, or a skill path inside
# that repo. Do not trust sibling repositories that merely share a prefix.
for trusted in TRUSTED_REPOS:
if normalized_source == trusted or normalized_source.startswith(f"{trusted}/"):
return "trusted"
return "community"
def _determine_verdict(findings: List[Finding]) -> str:
"""Determine the overall verdict from a list of findings."""
if not findings:
return "safe"
has_critical = any(f.severity == "critical" for f in findings)
has_high = any(f.severity == "high" for f in findings)
if has_critical:
return "dangerous"
if has_high:
return "caution"
# medium/low findings alone are informational, not blocking
return "safe"
def _build_summary(name: str, source: str, trust: str, verdict: str, findings: List[Finding]) -> str:
"""Build a one-line summary of the scan result."""
if not findings:
return f"{name}: clean scan, no threats detected"
categories = {f.category for f in findings}
return f"{name}: {verdict}{len(findings)} finding(s) in {', '.join(sorted(categories))}"