Files
hermes-agent/tools/skills_guard.py
teknium1 4586cde64d chore: mark the deliberate /tmp literals and shrink the lint baseline to one code block
The seventeen remaining literals are container-side paths, AF_UNIX socket-path-limit
candidates on darwin, detection needles, guard regexes and guidance text that tells the
model to avoid /tmp. Each carries an inline `no-tmp: ok — <why>` so the reason lives
next to the line; the baseline keeps only a fenced tree listing where a marker would render.
2026-09-19 10:44:26 -07:00

812 lines
53 KiB
Python

#!/usr/bin/env python3
"""Skills Guard — regex static scan of externally-sourced skills plus a trust-aware install policy.
Trust: builtin (never scanned), trusted (openai/anthropics/... repos: caution allowed), community (any
findings block unless --force). ``scan_skill`` -> ``should_allow_install`` -> ``format_scan_report``.
Known gap: 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 dynamic
destination; 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-v5"
# 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" | ...
file: str
line: int
match: str
description: str
@dataclass
class ScanResult:
skill_name: str
source: str
trust_level: str # "builtin" | "trusted" | "community" | "agent-created"
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 (source arg 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+(?:contain|say|include|have|list)\b'
# ── context_exfil helpers ──
# Negation guard: never/not/doesn't ... right after the verb marks descriptive prose (subagent
# isolation notes, release notes) — the opposite of a transfer directive.
_NO_TRANSFER = (r'(?!(?:\w+\s+){0,4}?(?:never|not|doesn\'?t|didn\'?t|won\'?t|isn\'?t|aren\'?t|can\'?t|cannot|mustn\'?t|shouldn\'?t)\b)')
# Real directives are short; unbounded filler let prose (output never enters your own context)
# and feature descriptions match.
_SHORT_FILLER = r'(?:\w+\s+){0,3}?'
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"),
(r'\b(?:readFile(?:Sync)?|readTextFile)\s*\(\s*["\'][^"\'\n]*(?:\.ssh[/\\]id_(?:rsa|ed25519|ecdsa|dsa)(?!\.pub)|\.env\b|credentials\b|\.netrc\b|\.pgpass\b|\.npmrc\b|\.pypirc\b)[^"\'\n]*["\']',
"js_read_secrets_file", "critical", "exfiltration", "JavaScript reads a known credential 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 ──
# Exfil puts the data in the queried NAME: the first positional argument (after
# optional -flags with values, +opts, @server) carries the interpolation. Anything
# looser fires on the English noun in prose ("set the host value and run
# `${SKILL_DIR}/x`") and on flag names such as llama.cpp `--host 127.0.0.1 --port $PORT`.
(r'(?<![-/])\b(dig|nslookup|host)\s+(?:[-+@]\S*(?:\s+[^\s$"\'-][^\s$]*)?\s+)*["\']?[^\s"\'$]*\$',
"dns_exfil", "critical", "exfiltration", "DNS lookup with variable interpolation (possible DNS exfiltration)"),
(r'>\s*/tmp/[^\s]*\s*&&\s*(curl|wget|nc|python)', # no-tmp: ok — malicious-pattern regex
"tmp_staging", "critical", "exfiltration", "writes to /tmp then exfiltrates"), # no-tmp: ok — malicious-pattern label
# ── 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 ──
# Cleanup under the standard temp roots (/tmp, /var/tmp, /dev/shm, /run) is routine in
# test/smoke scripts and CI. A parent segment inside an exempted root can escape it,
# so it remains destructive along with every other path rooted at "/".
(r'rm\s+-rf\s+/(?:'
r'(?!tmp(?:\b|/)|var/tmp(?:\b|/)|dev/shm(?:\b|/)|run(?:\b|/))'
r'|(?:tmp|var/tmp|dev/shm|run)/(?:[^/\s]*/)*\.\.(?=/|[\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"),
# ``profile`` is split out and anchored: ``.zshrc`` after a dot is always the file, but
# ``.profile`` is also how every language spells attribute access (``self.profile``,
# ``data?.profile``, ``func().profile``), which flooded scans of ordinary code. Requiring
# a non-identifier, non-call/index/optional-chain character before the dot keeps real paths
# (``~/.profile``, ``"$HOME/.profile"``, ``./.profile``) and drops attribute reads.
(r'\.(bashrc|zshrc|bash_profile|bash_login|zprofile|zlogin)\b',
"shell_rc_mod", "medium", "persistence", "references shell startup file"),
(r'(?<![\w)\]?])\.profile\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 ──
# socat needs an address spec (TCP:/EXEC:/…): a bare word match hit "SOCAT", the
# oceanographic CO2 atlas, across dozens of science skills (all patterns are IGNORECASE).
(r'\bnc\s+-[lp]|ncat\s+-[lp]|\bsocat\b[^\n]*\b(?:tcp|udp|openssl|ssl|exec|system|pty|unix)[\w-]*:',
"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)"),
# `sudo.request` / `sudo.respond` are gateway wire events (the masked sudo-password prompt), not an
# invocation: any client plugin that relays Hermes' secure prompts has to name them, and a bare
# `\bsudo\b` made every such plugin `caution`. A dotted event name is never a shell `sudo`.
(r'\bsudo\b(?!\.(?:request|respond)\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.
# Flagging any mention as critical produced permanent false-positive blocks for popular community skills
# (#92021). * 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.
(_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 ──
# Instruction shapes only. Descriptive prose about context handling ("The output never enters
# your own context", "**Include context:** cwd, env vars", "save tokens (no need to include code
# in context)") describes the OPPOSITE of exfiltration and must not match: the verb→target gap is
# bounded, a negation right after the verb voids the match, and a bare ``context`` target counts
# only under transfer verbs (print/send/share) — "include context" is window/information talk.
(rf'\b(?:include|output|print|send|share)\s+{_NO_TRANSFER}{_SHORT_FILLER}(?:conversation|chat\s+history|previous\s+messages)\b'
rf'|\b(?:print|send|share)\s+{_NO_TRANSFER}{_SHORT_FILLER}context\b',
"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), *rest) for pattern, *rest in THREAT_PATTERNS]
# ── Inert path references (#92478) ──
# The bare path-token patterns (`authorized_keys`, `~/.aws`, ...) fire on the token alone, so a skill that spells
# a path in order to REFUSE it scores like one that appends to it, and one critical is an unoverridable dangerous
# verdict on a community source. Two contexts DEMOTE the finding (it stays in the report with file and line, like
# `allowed_tools_field`): a whole-line comment in a language that has comments drops to low — Markdown is
# deliberately absent, `#` opens a heading there and the prose is the injection surface; a line owned by a
# denylist-named assignment, with no verb on the line that could touch the path, drops to high, so the verdict
# is a confirmable caution rather than a hard block. The name is attacker-chosen, hence the verb guard.
_PATH_REFERENCE_PATTERN_IDS = frozenset({"ssh_dir_access", "aws_dir_access", "gpg_dir_access", "kube_dir_access",
"docker_dir_access", "ssh_backdoor", "system_passwd_access"})
_COMMENT_PREFIX = {'.py': '#', '.sh': '#', '.bash': '#', '.rb': '#', '.pl': '#', '.r': '#', '.jl': '#', '.yaml': '#',
'.yml': '#', '.toml': '#', '.conf': '#', '.cfg': ('#', ';'), '.ini': ('#', ';'), '.js': '//',
'.ts': '//', '.php': ('//', '#')}
# `NAME = ...`, `NAME: Type = ...`, `const NAME = ...` or a mapping key `name:` whose name says "not these".
_DENYLIST_OWNER_RE = re.compile(
r'^\s*(?:(?:const|let|var|export)\s+)?[\w.\-]*(?:deny|black|block|skip|exclu|ignor|forbid|refus|reject|never'
r'|unsafe|sensitive|secret_?file|redact)[\w.\-]*\s*(?::[^=]*?)?(?:=|:\s*(?:$|[\[({]))', re.IGNORECASE)
# Anything on the line that could act on the path: shell verb, file API, spawn, append redirection. Bare `|` and
# `>` are excluded on purpose — both are regex metacharacters and the reported fragment is `r"|authorized_keys"`.
_ACTION_ON_LINE_RE = re.compile(
r'\b(?:cat|less|more|head|tail|cp|mv|rm|scp|rsync|curl|wget|tee|chmod|chown|ssh|sudo|install|source|eval|exec'
r'|system|popen|run|check_output|copyfile|copy2|sendfile)\b|\.(?:read|write|open|unlink|copy|append)\w*\s*\('
r'|\bopen\s*\(|readFileSync|writeFileSync|appendFileSync|>>', re.IGNORECASE)
_QUOTED_SPAN_RE = re.compile(r"""'[^'\n]*'|"[^"\n]*\"""")
def _statement_owners(lines: list) -> list:
"""0-indexed line that opened each line's statement (an unclosed bracket or trailing backslash continues it),
so a match on a continuation line of a multi-line regex can be traced to the assignment target above it.
Quoted spans are blanked before counting brackets; a miscount costs a missed demotion, never a missed finding."""
owners, depth, owner, continued = [], 0, 0, False
for i, line in enumerate(lines):
if depth <= 0 and not continued:
owner = i
owners.append(owner)
bare = _QUOTED_SPAN_RE.sub("", line)
depth = max(0, depth + sum(bare.count(c) for c in "([{") - sum(bare.count(c) for c in ")]}"))
continued = line.rstrip().endswith("\\")
return owners
def _demote_inert_path_reference(pid: str, severity: str, description: str, line: str, owner_line: str,
suffix: str) -> Tuple[str, str]:
"""``(severity, description)`` for a path-token match, lowered when the line cannot act where it sits."""
if pid not in _PATH_REFERENCE_PATTERN_IDS:
return severity, description
if line.lstrip().startswith(_COMMENT_PREFIX.get(suffix, ())):
return "low", f"{description} (in a comment; informational)"
if _DENYLIST_OWNER_RE.match(owner_line) and not _ACTION_ON_LINE_RE.search(line):
return "high", f"{description} (in a denylist literal; confirm before installing)"
return severity, description
# Structural limits: file count; total KB (5MB, informational only — large skills don't block); single-file KB.
MAX_FILE_COUNT, MAX_TOTAL_SIZE_KB, MAX_SINGLE_FILE_KB = 50, 5120, 256
# 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)
_PATH_TRAVERSAL_PATTERN_IDS = {"path_traversal", "path_traversal_deep"}
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 lines inside or on the boundary of triple-quoted strings (opening, interior, closing, and
one-line docstrings), so ``os.environ`` in prose is not scored. Heuristic: a triple quote inside a string
literal is miscounted, but the common false-positive shapes are covered."""
doc_lines: set = set()
inside = False
for i, line in enumerate(lines, start=1):
was_in, counts = inside, [line.count(marker) for marker in ('"""', "'''")]
inside ^= sum(counts) % 2 == 1 # each odd marker count toggles; two odd counts cancel
if was_in or inside or any(counts):
doc_lines.add(i)
return doc_lines
def _mask_markdown_link_destinations(line: str) -> str:
"""Blank balanced inline-link destinations while preserving line offsets.
A relative Markdown destination describes documentation structure; it does
not cause filesystem access. Nested parentheses and escaped characters are
handled so a later, non-link traversal on the same line remains scannable.
"""
masked = list(line)
search_from = 0
while (start := line.find("](", search_from)) != -1:
depth = 1
escaped = False
cursor = start + 2
while cursor < len(line):
char = line[cursor]
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
masked[start + 2:cursor] = " " * (cursor - start - 2)
search_from = cursor + 1
break
cursor += 1
else:
break
return "".join(masked)
# A fenced code block opens with 3+ backticks or 3+ tildes indented at most 3 spaces (CommonMark
# §4.5); a backtick fence's info string may not contain a backtick. It closes only on a line whose
# fence uses the same marker, is at least as long, and carries nothing else — so a ``~~~`` line
# inside a backtick fence, a shorter fence, or a fence line with an info string is all content.
_FENCE_LINE = re.compile(r"^ {0,3}(?P<marker>`{3,}|~{3,})(?P<info>.*)$")
# A fence may open (and close) inside a container: a bullet ``- ```sh``, an ordered item ``1. ```sh``,
# a blockquote ``> ```sh``, or a nest of them (§5.1/§5.2). Strip those prefixes before fence matching.
_CONTAINER_PREFIX = re.compile(r"^(?: {0,3}(?:>|(?:[-*+]|\d{1,9}[.)]) {1,4}))+")
def _mask_prose_link_destinations(lines: List[str]) -> List[str]:
"""Mask link destinations only in Markdown prose. Inside a fenced or indented code block a
``[x](../..)`` is an argument to whatever command surrounds it, not a hyperlink, so those lines
scan verbatim. Fence state is ``(marker_char, opener_length)`` rather than a bool so a
mismatched fence line cannot drop the scanner back into prose mode; an unclosed fence stays
code to EOF (fail-safe)."""
out: List[str] = []
fence = None # (marker char, opener length) while a fenced block is open
for line in lines:
match = _FENCE_LINE.match(_CONTAINER_PREFIX.sub("", line))
if fence is not None:
if (match and match["marker"][0] == fence[0] and len(match["marker"]) >= fence[1]
and not match["info"].strip()):
fence = None
code = True
else:
if match and not (match["marker"][0] == "`" and "`" in match["info"]):
fence = (match["marker"][0], len(match["marker"]))
code = fence is not None or line.startswith(("\t", " ")) # indented code block (§4.4)
out.append(line if code else _mask_markdown_link_destinations(line))
return out
def scan_file(file_path: Path, rel_path: str = "") -> List[Finding]:
"""Threat-pattern + invisible-unicode scan of one file; *rel_path* is the display path (default: 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:
lines = file_path.read_text(encoding='utf-8').split('\n')
except (UnicodeDecodeError, OSError):
return []
findings = []
docstring_lines = _compute_docstring_lines(lines) # so code patterns don't fire on prose
traversal_lines = _mask_prose_link_destinations(lines) if file_path.suffix.lower() == ".md" else lines
suffix, owners = file_path.suffix.lower(), _statement_owners(lines) # per-file context for the demotion
for pattern, pid, severity, category, description in _COMPILED_THREAT_PATTERNS:
for i, line in enumerate(lines, start=1):
scan_line = traversal_lines[i - 1] if pid in _PATH_TRAVERSAL_PATTERN_IDS else line
if i not in docstring_lines and pattern.search(scan_line):
text = line.strip()
line_severity, line_description = _demote_inert_path_reference(
pid, severity, description, line, lines[owners[i - 1]], suffix)
findings.append(Finding(pid, line_severity, category, rel_path, i,
text if len(text) <= 120 else text[:117] + "...", line_description))
for i, line in enumerate(lines, start=1):
if (char := next((c for c in INVISIBLE_CHARS if c in line), None)) is not None:
name = _unicode_char_name(char)
findings.append(Finding("invisible_unicode", "high", "injection", rel_path, i,
f"U+{ord(char):04X} ({name})",
f"invisible unicode character {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 gitignore-style
`.skillignore` / `.clawhubignore` 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."""
name, trust = skill_path.name, _resolve_trust_level(source)
findings: List[Finding] = []
if skill_path.is_dir():
ignore = _load_skill_ignore(skill_path)
findings.extend(_check_structure(skill_path, ignore=ignore))
for f in skill_path.rglob("*"):
if f.is_file() and not ignore(rel := str(f.relative_to(skill_path))):
findings.extend(scan_file(f, rel))
elif skill_path.is_file():
findings.extend(scan_file(skill_path, skill_path.name))
verdict = _determine_verdict(findings)
return ScanResult(name, source, trust, verdict, findings, datetime.now(timezone.utc).isoformat(),
_build_summary(name, source, trust, verdict, findings))
def _content_digest(skill_path: Path) -> str:
"""Canonical SHA-256 over (POSIX relative path, file bytes) ORDERED by the rel-path STRING — Path sorting is
case-insensitive on Windows and diverged from ``skills_hub.bundle_content_hash`` (every installed skill then
reported ``update_available`` forever). String order keeps both sides byte-symmetric.
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).
"""
if not skill_path.is_dir():
return hashlib.sha256(skill_path.read_bytes()).hexdigest()
h = hashlib.sha256()
for rel, p in sorted((p.relative_to(skill_path).as_posix(), p) for p in skill_path.rglob("*") if p.is_file()):
h.update(rel.encode("utf-8") + b"\x00")
h.update(p.read_bytes())
return h.hexdigest()
def content_hash(skill_path: Path) -> str:
"""Short integrity hash (paths mixed in, so swapping two files' contents changes it). MUST stay symmetric
with ``tools.skills_hub_install.bundle_content_hash`` — 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]:
"""Scan plus attestation dict; the cache (keyed by content digest + source identity) only serves exact
current content under the current scanner version."""
digest = _content_digest(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"{digest}-{source_identity}.json"
expected = {"bundle_hash": f"sha256:{digest}", "scanner_version": SCANNER_VERSION, "source": source,
"source_url": source_url}
cached = None
with suppress(OSError, json.JSONDecodeError):
cached = json.loads(cache_file.read_text(encoding="utf-8"))
if isinstance(cached, dict) and all(cached.get(k) == v for k, v in expected.items()):
result = ScanResult(skill_path.name, source, cached["trust_level"], cached["verdict"],
[Finding(**item) for item in cached.get("findings", [])], cached["scanned_at"],
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]:
"""``(allowed, reason)`` from verdict + trust; *force* overrides every block except a dangerous verdict on
community/trusted sources. ``allowed`` is None when policy says "ask"."""
decision = INSTALL_POLICY.get(result.trust_level, INSTALL_POLICY["community"])[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)"
blocked = f"Blocked ({result.trust_level} source + {result.verdict} verdict, {n} findings). "
return False, blocked + ("--force does not override a dangerous verdict." if hard_block else "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:
order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
for f in sorted(result.findings, key=lambda f: order.get(f.severity, 4)):
lines.append(f" {f.severity.upper().ljust(8)} {f.category.ljust(14)} "
f"{f'{f.file}:{f.line}'.ljust(30)} \"{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"
return "\n".join(lines + [f"Decision: {status} — {reason}"])
def _check_structure(skill_dir: Path, ignore=None) -> List[Finding]:
"""Structural anomalies (counts, sizes, binaries, stray executables, escaping symlinks); *ignore(rel) -> bool*
excludes paths from every count and finding."""
findings = []
def add(pid, sev, cat, rel, match, desc):
findings.append(Finding(pid, sev, cat, rel, 0, match, desc))
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 is not None and 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:
st = f.stat()
except OSError:
continue
total_size += (size := st.st_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)")
if (ext := f.suffix.lower()) 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 st.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")
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):
with suppress(UnicodeDecodeError, OSError):
if ig.is_file():
patterns.extend(s for s in map(str.strip, ig.read_text(encoding="utf-8").splitlines())
if s and not s.startswith("#"))
def ignore(rel: str) -> bool:
rel_posix = Path(rel).as_posix()
segs = rel_posix.split("/")
base = segs[-1]
if base == "SKILL.md":
return False
if base in _SKILL_IGNORE_FILENAMES:
return True
for pat in patterns:
anchored = pat.startswith("/")
p = pat.strip("/")
if not p:
continue
below = rel_posix.startswith(p + "/")
if pat.endswith("/"): # 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
# Unanchored: also the basename, any path segment, or a prefix dir (`docs` ignores docs/plans/x.md).
elif fnmatch.fnmatch(rel_posix, p) or (not anchored and (fnmatch.fnmatch(base, p) or below or (
"/" not in p and any(fnmatch.fnmatch(seg, p) for seg in segs)))):
return True
return False
return ignore
_SOURCE_PREFIX_ALIASES = ("skills-sh/", "skills.sh/", "skils-sh/", "skils.sh/")
def _resolve_trust_level(source: str) -> str:
"""Source id -> 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 sharing the prefix."""
src = source[len(next((p for p in _SOURCE_PREFIX_ALIASES if source.startswith(p)), "")):]
if src == "agent-created":
return "agent-created"
if src == "official":
return "builtin"
return "trusted" if any(src == t or src.startswith(f"{t}/") for t in TRUSTED_REPOS) else "community"
def _determine_verdict(findings: List[Finding]) -> str:
"""critical → dangerous, high → caution; medium/low alone are informational (safe)."""
sev = {f.severity for f in findings}
return "dangerous" if "critical" in sev else "caution" if "high" in sev 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}))}"
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def full_content_hash(skill_path: Path) -> str:
"""Full canonical digest used to bind scanner attestations."""
return f"sha256:{_content_digest(skill_path)}"
# ---- END PLUGIN-COMPAT ----