Files
hermes-agent/tools/spill_safety.py
T
Teknium d4cec15b47 refactor(tools): first-wave simplification of tools/ (file ops split, lazy_deps, code_exec, approval, browser, delegate, mcp, skills, terminal, voice, media)
Behavior-neutral structural pass over tools/*: god-file extractions into
sibling modules (file_operations_common/lint/search, file_tools_paths/
read_tracking/write, code_execution_env/rpc, tool_search_catalog/names/
validation, tts_command_provider, ...), duplicate helper unification,
if/elif -> dispatch tables, dead-code removal, docstring compaction.
Tool schemas (get_tool_definitions) verified byte-identical to base.
2026-09-02 14:43:45 -07:00

102 lines
3.4 KiB
Python

"""Symlink-safe creation helpers for spill/cache files.
Spill files (terminal output, hook context, subagent summaries, web_extract
text) live in predictable directories under ``~/.hermes``; a plain
``open(path, "w")`` there would follow a pre-planted symlink and let a local
process redirect the write onto ``~/.bashrc``, ``authorized_keys``, etc.
Every helper refuses symlinks by construction: new files use
``O_CREAT | O_EXCL`` (fails on ANY existing path, including a dangling link);
overwrites ``lstat`` + ``unlink`` the existing path first (removes the link,
never its target) and then create exclusively, so the pair can't be raced.
Privacy tiers: ``private=True`` (default) forces ``0o700`` dirs / ``0o600``
files for spills that may hold pre-redaction secrets; ``private=False`` keeps
umask-default perms for cache dirs bind-mounted into remote terminal backends
(``credential_files._CACHE_DIRS``), where a non-root container UID must read them.
Disk failures are the caller's concern: helpers raise ``OSError``.
"""
from __future__ import annotations
import os
import stat
from pathlib import Path
from typing import IO
__all__ = [
"ensure_spill_dir",
"open_exclusive",
"write_text_exclusive",
]
# O_NOFOLLOW is POSIX-only; on Windows O_EXCL alone already refuses every
# pre-existing path.
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
def ensure_spill_dir(path: Path, *, private: bool = True) -> Path:
"""Create ``path`` (and parents) as a directory, refusing symlinks.
``private=True`` creates the leaf ``0o700`` and tightens an existing leaf to
``0o700``. Raises ``OSError`` if the leaf is not a real directory.
"""
path = Path(path)
path.mkdir(mode=0o700 if private else 0o777, parents=True, exist_ok=True)
st = os.lstat(path)
if not stat.S_ISDIR(st.st_mode):
raise OSError(f"spill dir is not a directory (symlink?): {path}")
if private and stat.S_IMODE(st.st_mode) != 0o700:
os.chmod(path, 0o700)
return path
def open_exclusive(
path: Path,
*,
private: bool = True,
overwrite: bool = False,
encoding: str = "utf-8",
errors: str = "strict",
) -> IO[str]:
"""Open ``path`` for writing via exclusive create; never follows a link.
``overwrite=True`` first unlinks an existing path (``lstat``-checked, so only
the link itself is removed and directories are refused), then creates
exclusively — the overwrite path cannot be redirected through a symlink either.
"""
path = Path(path)
if overwrite:
try:
st = os.lstat(path)
except FileNotFoundError:
pass
else:
if stat.S_ISDIR(st.st_mode):
raise OSError(f"refusing to overwrite a directory: {path}")
os.unlink(path)
mode = 0o600 if private else 0o666 # non-private honors umask
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW, mode)
try:
return os.fdopen(fd, "w", encoding=encoding, errors=errors)
except Exception:
os.close(fd)
raise
def write_text_exclusive(
path: Path,
text: str,
*,
private: bool = True,
overwrite: bool = False,
encoding: str = "utf-8",
errors: str = "strict",
) -> None:
"""``Path.write_text`` equivalent that refuses to follow symlinks."""
with open_exclusive(
path, private=private, overwrite=overwrite, encoding=encoding, errors=errors
) as fh:
fh.write(text)