Files
hermes-agent/tools/skills_guard.py
T

680 lines
43 KiB
Python

#!/usr/bin/env python3
"""Skills Guard — regex static scan of externally-sourced skills plus a trust-aware install policy.
Trust: builtin (ships with Hermes, never scanned), trusted (openai/anthropics/... repos: caution allowed),
community (any findings block unless --force). ``scan_skill(path, source)`` -> ``should_allow_install`` ->
``format_scan_report``. Known limitation: language write APIs (open(..., 'w'), Path.write_text, shutil.copy*,
fs.writeFileSync) aimed at agent-config files surface only the low *_ref finding — static regexes cannot tie
the call to a dynamically built destination; any future coverage belongs as a fourth "mechanical" tier next
to agent_config_mod_shell."""
import re
import fnmatch
import hashlib
import json
from contextlib import suppress
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Tuple
SCANNER_VERSION = "skills-guard-v2"
# NVIDIA-verified skills each ship a signed `skill.oms.sig` + governance `skill-card.md`.
TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills", "NVIDIA/skills"}
INSTALL_POLICY = {
# safe caution dangerous
"builtin": ("allow", "allow", "allow"),
"trusted": ("allow", "allow", "block"),
"community": ("allow", "block", "block"),
# "ask" = error to the agent (retry without the flagged content); only when skills.guard_agent_created is on.
"agent-created": ("allow", "allow", "ask"),
}
VERDICT_INDEX = {"safe": 0, "caution": 1, "dangerous": 2}
@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) --
# File-modification verbs for the agent-config persistence tiers: a verb shortly before a config
# filename on the same line 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)'
)
_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): consume trailing filename chars.
_OTHER_AGENT_CONFIG_FILES = r'\.(?:claude/settings|codex/config)[\w.]*'
def _shell_write_re(file_alt: str) -> str:
"""Mechanical shell write into *file_alt*: ``>``/``>>``, ``sed -i``, ``tee`` (target as immediate argument, so
``| tee output | AGENTS.md |`` cells miss), ``cp``/``mv`` with the file as destination (a source arg is required,
so ``cp AGENTS.md backup/`` misses; ``AGENTS.md.bak`` is not the file). A single ``>`` needs a preceding
word/quote/paren char so blockquotes (``> text``) and arrows (``-> file``) miss."""
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:
"""Prose instructing modification of *file_alt*: an imperative-position verb (line start / bullet), or a mid-line
verb with a directive marker ("you must", "please", "make sure to"). Descriptive prose ("skills that edit
AGENTS.md") misses; the verb→file gap forbids commas so enumerations ("Write skills, AGENTS.md, CLAUDE.md") miss."""
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:
""""<file> should contain/include ..." prose. Authoring guides and attacks share this shape and are not
separable statically, so the tier is scored high (caution → 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 ──
# env_exfil_* share a loopback exemption: a same-line literal scheme-anchored loopback destination
# (http(s)://localhost, 127.0.0.1, [::1]) cannot move data off the machine, so a secret-shaped query
# param there is a local session token. The scheme must immediately precede the host —
# `evil.com/?u=localhost` does not qualify.
(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"),
# `cat <secrets-file>` reads credentials; `cat >`/`cat >>` WRITES one (setup heredocs) — not exfil.
(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"),
# Bare `os.environ` (dump/iteration) is suspicious; ANY `.get("<name>")` form is exempt — plain config
# reads, with secret-shaped names scored medium by python_environ_get_secret below (a blanket high here
# would swamp that). `^[^#\n]*` skips lines with a '#' anywhere before it (full-line or inline comment);
# scan_file()'s docstring pre-filter skips triple-quoted prose.
(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 Ruby ENV: (?-i:) keeps Python `env[...]` dict access from matching 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"),
# Concealment only — the lookahead exempts UX guidance ("don't tell the user X unless Y confirms").
(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 frontmatter per the agent-skill spec — informational (low) only.
(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 ──
# Bare mentions of config files are not threats (authoring guides, setup docs) — flagging them blocked
# popular community skills. Tiers: mechanical shell writes = critical; prose modification intent =
# critical for AGENT config files (exactly how persistence attacks instruct the agent; project-skill
# quarantine only acts on "dangerous") but high for Hermes/other config (setup docs routinely say
# "edit config.yaml"); bare references = low.
(_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
# Text extensions to scan; known binary extensions that should NOT be in a skill; script types allowed +x.
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',
}
SUSPICIOUS_BINARY_EXTENSIONS = {
'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.com', '.msi', '.dmg', '.app', '.deb', '.rpm',
}
_SCRIPT_EXTENSIONS = {'.sh', '.bash', '.py', '.rb', '.pl'}
# Zero-width / directional unicode used for text hiding, with the readable name reported in the finding.
_INVISIBLE_CHAR_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",
}
INVISIBLE_CHARS = set(_INVISIBLE_CHAR_NAMES)
def _unicode_char_name(char: str) -> str:
return _INVISIBLE_CHAR_NAMES.get(char, f"U+{ord(char):04X}")
def _compute_docstring_lines(lines: list) -> set:
"""1-indexed line numbers inside (or on the boundary of) triple-quoted strings: opening, interior, closing and
self-contained one-line docstrings all count, so ``os.environ`` in prose is not scored. Heuristic (ignores a
triple quote inside a string literal) but covers the common false-positive shapes."""
doc_lines: set = set()
in_docstring = False
for i, line in enumerate(lines):
was_in = in_docstring
counts = [line.count(marker) for marker in ('"""', "'''")]
in_docstring ^= sum(counts) % 2 == 1 # each odd marker count toggles; two odd counts cancel
if was_in or in_docstring or any(counts):
doc_lines.add(i + 1)
return doc_lines
def scan_file(file_path: Path, rel_path: str = "") -> List[Finding]:
"""Scan one file for threat patterns and invisible unicode. *rel_path* is the display path (defaults
to the file name). Regex findings dedupe per pattern per line; invisible chars yield one per line."""
rel_path = rel_path or 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')
docstring_lines = _compute_docstring_lines(lines) # so code patterns don't fire on prose
for pattern, pid, severity, category, description in _COMPILED_THREAT_PATTERNS:
for i, line in enumerate(lines, start=1):
if i not in docstring_lines and pattern.search(line):
text = line.strip()
findings.append(Finding(pid, severity, category, rel_path, i,
text if len(text) <= 120 else text[:117] + "...", description))
for i, line in enumerate(lines, start=1):
char = next((c for c in INVISIBLE_CHARS if c in line), None)
if char is not None:
char_name = _unicode_char_name(char)
findings.append(Finding(
"invisible_unicode", "high", "injection", rel_path, i, f"U+{ord(char):04X} ({char_name})",
f"invisible unicode character {char_name} (possible text hiding/injection)"))
return findings
def scan_skill(skill_path: Path, source: str = "community") -> ScanResult:
"""Structural checks + pattern scan of every text file in a skill dir (or a single file). A `.skillignore` /
`.clawhubignore` (gitignore-style) excludes dev/docs artifacts from BOTH passes; the ignore file itself is
always excluded and `SKILL.md` can never be un-ignored. *source* (e.g. "openai/skills") sets the trust level."""
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)
all_findings.extend(_check_structure(skill_path, ignore=ignore))
for f in skill_path.rglob("*"):
rel = str(f.relative_to(skill_path))
if f.is_file() and not ignore(rel):
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)
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=_build_summary(skill_name, source, trust_level, verdict, all_findings),
)
def _content_digest(skill_path: Path) -> str:
"""Canonical SHA-256 over (POSIX relative path, file bytes), ORDERED by the rel-path STRING: sorting Paths is
case-insensitive on Windows and diverged from ``tools.skills_hub.bundle_content_hash`` (plain-string sort), so
every installed skill reported ``update_available`` forever. String order keeps both sides byte-symmetric."""
h = hashlib.sha256()
if not skill_path.is_dir():
h.update(skill_path.read_bytes())
return h.hexdigest()
entries = sorted((p.relative_to(skill_path).as_posix(), p) for p in skill_path.rglob("*") if p.is_file())
for rel, file_path in entries:
h.update(rel.encode("utf-8") + b"\x00")
h.update(file_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 content_hash(skill_path: Path) -> str:
"""Short integrity hash. Paths are mixed in so swapping two files' contents changes it. MUST stay
symmetric with ``tools.skills_hub.bundle_content_hash`` (disk vs in-memory bundle) — change both at once."""
return f"sha256:{_content_digest(skill_path)[:16]}"
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
expected = {"bundle_hash": bundle_hash, "scanner_version": SCANNER_VERSION, "source": source, "source_url": source_url}
if isinstance(cached, dict) and all(cached.get(k) == v for k, v in expected.items()):
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 = {**cached, "fresh": False}
else:
result = scan_skill(skill_path, source=source)
findings = [asdict(item) for item in result.findings]
provenance = {
**expected, "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}
with suppress(OSError):
cache_root.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
result.scan_provenance = provenance
return result, provenance
def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]:
"""Decide install from verdict + trust; ``(allowed, reason)``. *force* overrides every block except a
dangerous verdict on community/trusted sources. ``allowed`` is None when policy says "ask"."""
policy = INSTALL_POLICY.get(result.trust_level, INSTALL_POLICY["community"])
decision = policy[VERDICT_INDEX.get(result.verdict, 2)]
n = len(result.findings)
hard_block = result.verdict == "dangerous" and result.trust_level in ("community", "trusted")
if decision == "allow":
return True, f"Allowed ({result.trust_level} source, {result.verdict} verdict)"
if force and not hard_block:
return True, f"Force-installed despite {result.verdict} verdict ({n} findings)"
if decision == "ask":
return None, f"Requires confirmation ({result.trust_level} source + {result.verdict} verdict, {n} findings)"
if hard_block:
return False, (f"Blocked ({result.trust_level} source + dangerous verdict, {n} findings). "
"--force does not override a dangerous verdict.")
return False, (f"Blocked ({result.trust_level} source + {result.verdict} verdict, {n} findings). "
"Use --force to override.")
def format_scan_report(result: ScanResult) -> str:
"""Compact multi-line report for CLI/chat display; findings sorted critical → low."""
lines = [f"Scan: {result.skill_name} ({result.source}/{result.trust_level}) Verdict: {result.verdict.upper()}"]
if result.findings:
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
for f in sorted(result.findings, key=lambda f: severity_order.get(f.severity, 4)):
loc = f"{f.file}:{f.line}".ljust(30)
lines.append(f" {f.severity.upper().ljust(8)} {f.category.ljust(14)} {loc} \"{f.match[:60]}\"")
lines.append("")
allowed, reason = should_allow_install(result)
status = "ALLOWED" if allowed is True else "NEEDS CONFIRMATION" if allowed is None else "BLOCKED"
lines.append(f"Decision: {status} — {reason}")
return "\n".join(lines)
def _check_structure(skill_dir: Path, ignore=None) -> List[Finding]:
"""Structural anomalies: file count, total size, binary/executable files, symlinks escaping the skill
dir, oversized files. *ignore(rel_path) -> bool* excludes paths from every count and finding."""
if ignore is None:
ignore = lambda _rel: False # noqa: E731
findings = []
def add(pid: str, severity: str, category: str, rel: str, match: str, description: str) -> None:
findings.append(Finding(pid, severity, category, rel, 0, match, description))
file_count = total_size = 0
for f in skill_dir.rglob("*"):
rel = str(f.relative_to(skill_dir))
if not (f.is_file() or f.is_symlink()) or ignore(rel):
continue
file_count += 1
if f.is_symlink():
try:
resolved = f.resolve()
if not resolved.is_relative_to(skill_dir.resolve()):
add("symlink_escape", "critical", "traversal", rel, f"symlink -> {resolved}",
"symlink points outside the skill directory")
except OSError:
add("broken_symlink", "medium", "traversal", rel, "broken symlink", "broken or circular symlink")
continue
try:
size = f.stat().st_size
except OSError:
continue
total_size += size
if size > MAX_SINGLE_FILE_KB * 1024:
add("oversized_file", "medium", "structural", rel, f"{size // 1024}KB",
f"file is {size // 1024}KB (limit: {MAX_SINGLE_FILE_KB}KB)")
ext = f.suffix.lower()
if ext in SUSPICIOUS_BINARY_EXTENSIONS:
add("binary_file", "critical", "structural", rel, f"binary: {ext}",
f"binary/executable file ({ext}) should not be in a skill")
if ext not in _SCRIPT_EXTENSIONS and f.stat().st_mode & 0o111:
add("unexpected_executable", "medium", "structural", rel, "executable bit set",
"file has executable permission but is not a recognized script type")
if file_count > MAX_FILE_COUNT:
add("too_many_files", "medium", "structural", "(directory)", f"{file_count} files",
f"skill has {file_count} files (limit: {MAX_FILE_COUNT})")
if total_size > MAX_TOTAL_SIZE_KB * 1024: # informational only: large skills are legitimate
add("oversized_skill", "low", "structural", "(directory)", f"{total_size // 1024}KB total",
f"skill is {total_size // 1024}KB total (limit: {MAX_TOTAL_SIZE_KB}KB)")
return findings
# `.skillignore` is Hermes-native; `.clawhubignore` is honored for skills published through ClawHub.
_SKILL_IGNORE_FILENAMES = (".skillignore", ".clawhubignore")
_ALWAYS_IGNORED_NAMES = set(_SKILL_IGNORE_FILENAMES)
_NEVER_IGNORABLE = {"SKILL.md"}
def _load_skill_ignore(skill_dir: Path):
"""Build ``ignore(rel_posix_path) -> bool`` from `.skillignore` / `.clawhubignore`. gitignore basics: blank
lines and ``#`` comments skipped; trailing ``/`` = directory (it and everything under it); ``*``/``?`` globs via
fnmatch on the full path and each segment; leading ``/`` anchors to the root. Ignore files always excluded;
``SKILL.md`` never."""
patterns: List[str] = []
for ig in (skill_dir / name for name in _SKILL_IGNORE_FILENAMES):
try:
if ig.is_file():
lines = (raw.strip() for raw in ig.read_text(encoding="utf-8").splitlines())
patterns.extend(line for line in lines if line and not line.startswith("#"))
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
below = rel_posix.startswith(p + "/")
if is_dir: # the dir itself or anything under it; unanchored also as an inner path component
if rel_posix == p or below or (not anchored and ("/" + p + "/") in ("/" + rel_posix + "/")):
return True
continue
if fnmatch.fnmatch(rel_posix, p):
return True
# Unanchored: also the basename, any path segment, or a prefix dir (`docs` ignores docs/plans/x.md).
if not anchored and (fnmatch.fnmatch(base, p) or below
or ("/" not in p and any(fnmatch.fnmatch(seg, p) for seg in rel_posix.split("/")))):
return True
return False
return ignore
_SOURCE_PREFIX_ALIASES = ("skills-sh/", "skills.sh/", "skils-sh/", "skils.sh/")
def _resolve_trust_level(source: str) -> str:
"""Map a source identifier to a trust level. "official" is provenance, not a user-controlled GitHub
id like "official/<repo>". Trusted repos match exactly or as a skill path inside the repo — never a
sibling repo sharing the prefix."""
prefix = next((p for p in _SOURCE_PREFIX_ALIASES if source.startswith(p)), "")
normalized_source = source[len(prefix):]
if normalized_source == "agent-created":
return "agent-created"
if normalized_source == "official":
return "builtin"
if any(normalized_source == t or normalized_source.startswith(f"{t}/") for t in TRUSTED_REPOS):
return "trusted"
return "community"
def _determine_verdict(findings: List[Finding]) -> str:
"""critical → dangerous, high → caution; medium/low alone are informational (safe)."""
severities = {f.severity for f in findings}
return "dangerous" if "critical" in severities else "caution" if "high" in severities else "safe"
def _build_summary(name: str, source: str, trust: str, verdict: str, findings: List[Finding]) -> str:
if not findings:
return f"{name}: clean scan, no threats detected"
return f"{name}: {verdict} — {len(findings)} finding(s) in {', '.join(sorted({f.category for f in findings}))}"