fix: use os.pathsep, add tests, update tips for multi-root support

- Use os.pathsep instead of literal ':' so Windows paths (C:\dir) and
  the Windows separator ';' work correctly.
- Add 9 tests covering multi-root behavior: writes inside first/second
  root, writes outside all roots, trailing/leading/double separators,
  all-separators edge case, static deny priority, duplicate dedup.
- Update hermes_cli/tips.py tip string to mention multiple paths.
- Update docs to mention os.pathsep / ; on Windows.

Follow-up for salvaged PR #49557.
This commit is contained in:
kshitijk4poor 2026-06-27 03:33:57 +05:30 committed by kshitij
parent d15cc9bc83
commit cdb1dfbc49
5 changed files with 96 additions and 7 deletions

View file

@ -79,17 +79,18 @@ def build_write_denied_prefixes(home: str) -> list[str]:
def get_safe_write_roots() -> set[str]:
"""Return resolved HERMES_WRITE_SAFE_ROOT paths. Supports multiple directories
separated by ':' (Unix PATH-style). E.g., '/opt/data:/var/www/html'."""
separated by ``os.pathsep`` (``:`` on Unix, ``;`` on Windows).
E.g., ``/opt/data:/var/www/html`` on Unix, ``C:\\data;D:\\www`` on Windows."""
env = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
if not env:
return set()
roots: set[str] = set()
for path in env.split(":"):
for path in env.split(os.pathsep):
if path:
try:
resolved = os.path.realpath(os.path.expanduser(path))
roots.add(resolved)
except Exception:
except (OSError, ValueError):
continue
return roots
@ -132,7 +133,6 @@ def is_write_denied(path: str) -> bool:
safe_roots = get_safe_write_roots()
if safe_roots:
# Allow write if path is under ANY of the safe roots
allowed = False
for safe_root in safe_roots:
if resolved == safe_root or resolved.startswith(safe_root + os.sep):

View file

@ -389,7 +389,7 @@ TIPS = [
# --- Env Vars & Config Gates ---
"display.tool_progress_command: true exposes /verbose on messaging platforms; it's CLI-only by default.",
'HERMES_BACKGROUND_NOTIFICATIONS=result only pings when background tasks finish (vs all/error/off).',
'HERMES_WRITE_SAFE_ROOT restricts write_file and patch to a directory prefix; writes outside require approval.',
'HERMES_WRITE_SAFE_ROOT restricts write_file/patch to directory prefixes; multiple paths via os.pathsep (: or ;).',
'HERMES_IGNORE_RULES skips auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills.',
'HERMES_ACCEPT_HOOKS auto-approves unseen shell hooks declared in config.yaml without a TTY prompt.',
'auxiliary.goal_judge.model routes the /goal judge to a cheap fast model to keep loop cost near zero.',

View file

@ -79,6 +79,95 @@ class TestSafeWriteRoot:
assert _is_write_denied(os.path.expanduser("~/.ssh/id_rsa")) is True
class TestMultipleSafeWriteRoots:
"""HERMES_WRITE_SAFE_ROOT with multiple colon-separated directories."""
def test_write_inside_first_root_allowed(self, tmp_path: Path, monkeypatch):
root_a = tmp_path / "workspace_a"
root_b = tmp_path / "workspace_b"
child = root_a / "subdir" / "file.txt"
os.makedirs(child.parent, exist_ok=True)
os.makedirs(root_b, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{root_a}{os.pathsep}{root_b}")
assert _is_write_denied(str(child)) is False
def test_write_inside_second_root_allowed(self, tmp_path: Path, monkeypatch):
root_a = tmp_path / "workspace_a"
root_b = tmp_path / "workspace_b"
child = root_b / "subdir" / "file.txt"
os.makedirs(child.parent, exist_ok=True)
os.makedirs(root_a, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{root_a}{os.pathsep}{root_b}")
assert _is_write_denied(str(child)) is False
def test_write_outside_all_roots_denied(self, tmp_path: Path, monkeypatch):
root_a = tmp_path / "workspace_a"
root_b = tmp_path / "workspace_b"
outside = tmp_path / "other" / "file.txt"
os.makedirs(root_a, exist_ok=True)
os.makedirs(root_b, exist_ok=True)
os.makedirs(outside.parent, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{root_a}{os.pathsep}{root_b}")
assert _is_write_denied(str(outside)) is True
def test_trailing_separator_ignored(self, tmp_path: Path, monkeypatch):
root = tmp_path / "workspace"
inside = root / "file.txt"
os.makedirs(root, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{root}{os.pathsep}")
assert _is_write_denied(str(inside)) is False
def test_leading_separator_ignored(self, tmp_path: Path, monkeypatch):
root = tmp_path / "workspace"
inside = root / "file.txt"
os.makedirs(root, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{os.pathsep}{root}")
assert _is_write_denied(str(inside)) is False
def test_double_separator_ignored(self, tmp_path: Path, monkeypatch):
root_a = tmp_path / "workspace_a"
root_b = tmp_path / "workspace_b"
os.makedirs(root_a, exist_ok=True)
os.makedirs(root_b, exist_ok=True)
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", f"{root_a}{os.pathsep}{os.pathsep}{root_b}")
# Both roots should still be active
assert _is_write_denied(str(root_a / "file.txt")) is False
assert _is_write_denied(str(root_b / "file.txt")) is False
def test_all_separators_yields_empty_set(self, tmp_path: Path, monkeypatch):
target = tmp_path / "regular.txt"
monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", os.pathsep * 3)
assert _is_write_denied(str(target)) is False
def test_static_deny_still_wins_with_multiple_roots(self, tmp_path: Path, monkeypatch):
"""Static deny list takes priority even when multiple safe roots include home."""
root = tmp_path / "workspace"
os.makedirs(root, exist_ok=True)
monkeypatch.setenv(
"HERMES_WRITE_SAFE_ROOT",
f"{root}{os.pathsep}{os.path.expanduser('~')}",
)
assert _is_write_denied(os.path.expanduser("~/.ssh/id_rsa")) is True
def test_duplicate_roots_deduplicated(self, tmp_path: Path, monkeypatch):
root = tmp_path / "workspace"
inside = root / "file.txt"
os.makedirs(root, exist_ok=True)
monkeypatch.setenv(
"HERMES_WRITE_SAFE_ROOT",
f"{root}{os.pathsep}{root}",
)
assert _is_write_denied(str(inside)) is False
class TestCheckSensitivePathMacOSBypass:
"""Verify _check_sensitive_path blocks /private/etc paths (issue #8734)."""

View file

@ -632,7 +632,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us
| `HERMES_PREFILL_MESSAGES_FILE` | Path to a JSON file of ephemeral prefill messages injected at API-call time. |
| `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. |
| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). |
| `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that restricts `write_file`/`patch` writes; paths outside require approval. Supports multiple directories separated by `:` (e.g., `/opt/data:/var/www/html`). |
| `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that restricts `write_file`/`patch` writes; paths outside require approval. Supports multiple directories separated by `os.pathsep` (`:` on Unix, `;` on Windows). |
| `HERMES_DISABLE_LAZY_INSTALLS` | Internal bridge var set automatically in the official Docker image to prevent runtime dependency installs into the immutable `/opt/hermes` tree. The user-facing equivalent is `security.allow_lazy_installs: false` in `config.yaml`; do not set this in `.env`. |
| `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the "file changed since you read it" guard on `patch`/`write_file`. |
| `HERMES_CORE_TOOLS` | Comma-separated override for the canonical core tool list (advanced; rarely needed). |

View file

@ -559,7 +559,7 @@ Graph 事件Teams 会议、日历、聊天等)的入站变更通知监听
| `HERMES_PREFILL_MESSAGES_FILE` | 包含在 API 调用时注入的临时预填消息的 JSON 文件路径。 |
| `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false`——允许工具获取 localhost/私有网络 URL。gateway 模式下默认关闭。 |
| `HERMES_REDACT_SECRETS` | `true`/`false`——控制工具输出、日志和聊天响应中的密钥脱敏(默认:`true`)。 |
| `HERMES_WRITE_SAFE_ROOT` | 可选目录前缀,限制 `write_file`/`patch` 写入;超出范围的路径需要审批。支持多个目录,使用 `:` 分隔(例如:`/opt/data:/var/www/html`)。 |
| `HERMES_WRITE_SAFE_ROOT` | 可选目录前缀,限制 `write_file`/`patch` 写入;超出范围的路径需要审批。支持多个目录,使用 `os.pathsep` 分隔Unix 为 `:`Windows 为 `;`)。 |
| `HERMES_DISABLE_LAZY_INSTALLS` | 官方 Docker 镜像中自动设置的内部桥接变量,用于阻止运行时将依赖安装到不可变的 `/opt/hermes` 树。面向用户的等价配置是 `config.yaml` 中的 `security.allow_lazy_installs: false`;不要在 `.env` 中手动设置此变量。 |
| `HERMES_DISABLE_FILE_STATE_GUARD` | 设为 `1` 可关闭 `patch`/`write_file` 上的"文件自上次读取后已更改"保护。 |
| `HERMES_CORE_TOOLS` | 规范核心工具列表的逗号分隔覆盖(高级;极少需要)。 |