File size: 7,304 Bytes
2e658e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
#!/usr/bin/env python3
"""P0-T05: strict backup allowlist and secret-pattern scanning.

Used by save_to_dataset.py / save_to_dataset_atomic.py / sync_hf persistence
paths so that .env, exchange keys, tokens, cookies, sessions with secrets,
and private configuration never enter a Dataset backup.
"""
from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Iterable, List, Optional, Sequence, Tuple

# Directories under HERMES_HOME that may be included in a backup.
# Anything not listed is excluded by default (fail-closed).
ALLOWED_DIR_NAMES = frozenset({
    "memories",
    "skills",
    "plans",
    "skins",
    "cron",
    "hooks",
})

# Explicit file names (basename) that may be included when they sit at the
# HERMES_HOME root or under an allowed directory.
ALLOWED_FILE_NAMES = frozenset({
    "telegram_state.json",
    "futures_symbols_cache.json",
    "SOUL.md",
    "soul.md",
    "AGENTS.md",
    "agents.md",
    "preferences.json",
    "hermes_domain.sqlite3",
})

# Basename patterns that are always excluded.
DENIED_BASENAME_PATTERNS = (
    re.compile(r"^\.env($|\.)", re.I),
    re.compile(r".*\.pem$", re.I),
    re.compile(r".*\.key$", re.I),
    re.compile(r".*credential.*", re.I),
    re.compile(r".*secret.*", re.I),
    re.compile(r".*token.*", re.I),
    re.compile(r".*cookie.*", re.I),
    re.compile(r".*password.*", re.I),
    re.compile(r"^id_rsa", re.I),
    re.compile(r"^id_ed25519", re.I),
    re.compile(r".*\.p12$", re.I),
    re.compile(r".*\.pfx$", re.I),
)

# Content patterns that cause a file to be rejected (secret-like).
SECRET_CONTENT_PATTERNS = (
    re.compile(r'''(?i)['"]?(api[_-]?key|api[_-]?secret|access[_-]?token)['"]?\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}'''),
    re.compile(r'''(?i)['"]?(password|passwd|secret)['"]?\s*[:=]\s*['"]?[^\s'"]{8,}'''),
    re.compile(r"(?i)-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----"),
    re.compile(r"(?i)(hf_|sk-|xox[baprs]-)[A-Za-z0-9]{20,}"),
    re.compile(r"(?i)bearer\s+[A-Za-z0-9\-_\.]{20,}"),
)

# Max bytes scanned per file for secret content (avoid huge binary reads).
_SECRET_SCAN_LIMIT = 64 * 1024


class BackupAllowlistError(RuntimeError):
    """Raised when a backup must abort because secret-like content was found."""


def is_denied_basename(name: str) -> bool:
    base = os.path.basename(name)
    return any(p.search(base) for p in DENIED_BASENAME_PATTERNS)


def is_path_allowed(rel_path: str, *, root_allowed_dirs: Optional[Sequence[str]] = None) -> bool:
    """Return True if relative path is within the strict allowlist."""
    rel = rel_path.replace("\\", "/").lstrip("./")
    if not rel or rel in (".",):
        return True  # directory root marker
    if is_denied_basename(rel):
        return False
    parts = [p for p in rel.split("/") if p and p != "."]
    if not parts:
        return True
    allowed_dirs = frozenset(root_allowed_dirs) if root_allowed_dirs is not None else ALLOWED_DIR_NAMES
    # Single file at root
    if len(parts) == 1:
        return parts[0] in ALLOWED_FILE_NAMES or parts[0] in allowed_dirs
    # Nested: first component must be an allowed directory
    if parts[0] not in allowed_dirs:
        return False
    # Still deny secret-named files deeper in the tree
    if is_denied_basename(parts[-1]):
        return False
    return True


def scan_file_for_secrets(path: Path) -> Optional[str]:
    """Return a short reason if the file appears to contain secrets, else None."""
    try:
        if not path.is_file():
            return None
        if path.stat().st_size == 0:
            return None
        # Skip obvious binaries by extension
        if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".zip", ".gz", ".tar", ".pyc", ".so"}:
            return None
        with open(path, "rb") as f:
            raw = f.read(_SECRET_SCAN_LIMIT)
        try:
            text = raw.decode("utf-8", errors="ignore")
        except Exception:
            return None
        for pat in SECRET_CONTENT_PATTERNS:
            if pat.search(text):
                return f"secret_pattern:{pat.pattern[:40]}"
    except OSError:
        return None
    return None


def filter_tarinfo(info, *, root: Optional[str] = None):
    """tarfile filter: return None to exclude, or the TarInfo to keep.

    Compatible with tarfile.TarFile.add(..., filter=...).
    """
    name = info.name
    # Always drop locks/tmp/pycache
    if name.endswith((".lock", ".tmp", ".pid", ".socket")):
        return None
    if "__pycache__" in name or name.endswith(".pyc"):
        return None
    if not is_path_allowed(name):
        return None
    return info


def collect_allowed_paths(state_dir: str) -> Tuple[List[Path], List[str]]:
    """Walk state_dir and return (allowed_files, rejection_reasons).

    If any allowed candidate fails secret content scan, raise BackupAllowlistError
    so the backup aborts rather than uploading partial secret material.
    """
    root = Path(state_dir)
    allowed: List[Path] = []
    rejections: List[str] = []
    if not root.is_dir():
        return allowed, rejections

    for dirpath, dirnames, filenames in os.walk(root):
        # Prune denied / non-allowlisted directories early
        rel_dir = os.path.relpath(dirpath, root).replace("\\", "/")
        if rel_dir == ".":
            # Only descend into allowlisted top-level dirs
            dirnames[:] = [d for d in dirnames if d in ALLOWED_DIR_NAMES and not is_denied_basename(d)]
        else:
            if not is_path_allowed(rel_dir):
                dirnames[:] = []
                continue
            dirnames[:] = [d for d in dirnames if not is_denied_basename(d)]

        for fn in filenames:
            full = Path(dirpath) / fn
            rel = os.path.relpath(full, root).replace("\\", "/")
            if not is_path_allowed(rel):
                rejections.append(f"excluded:{rel}")
                continue
            secret_reason = scan_file_for_secrets(full)
            if secret_reason:
                raise BackupAllowlistError(
                    f"Refusing backup: secret-like content in {rel} ({secret_reason})"
                )
            allowed.append(full)
    return allowed, rejections


def tar_filter_factory(state_dir: str):
    """Return a tar filter that enforces the allowlist relative to state_dir."""
    root = Path(state_dir)

    def _filter(info):
        # info.name is arcname-relative (often ".")
        name = info.name
        if name in (".", ""):
            return info
        # When arcname is ".", members look like "./sessions/..." or "sessions/..."
        rel = name.lstrip("./")
        if rel.endswith((".lock", ".tmp", ".pid", ".socket")):
            return None
        if "__pycache__" in rel or rel.endswith(".pyc"):
            return None
        if not is_path_allowed(rel):
            return None
        # Content scan for regular files
        if info.isfile():
            candidate = root / rel
            if candidate.is_file():
                reason = scan_file_for_secrets(candidate)
                if reason:
                    raise BackupAllowlistError(
                        f"Refusing backup: secret-like content in {rel} ({reason})"
                    )
        return info

    return _filter