Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions agent/file_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,22 @@ def build_write_denied_prefixes(home: str) -> list[str]:
]


def get_safe_write_root() -> Optional[str]:
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
if not root:
return None
try:
return os.path.realpath(os.path.expanduser(root))
except Exception:
return None
def get_safe_write_roots() -> set[str]:
"""Return resolved HERMES_WRITE_SAFE_ROOT paths. Supports multiple directories
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(os.pathsep):
if path:
try:
resolved = os.path.realpath(os.path.expanduser(path))
roots.add(resolved)
except (OSError, ValueError):
continue
return roots


def is_write_denied(path: str) -> bool:
Expand Down Expand Up @@ -124,9 +131,15 @@ def is_write_denied(path: str) -> bool:
except Exception:
pass

safe_root = get_safe_write_root()
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
return True
safe_roots = get_safe_write_roots()
if safe_roots:
allowed = False
for safe_root in safe_roots:
if resolved == safe_root or resolved.startswith(safe_root + os.sep):
allowed = True
break
if not allowed:
return True

return False

Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@
# --- 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.',
Expand Down
89 changes: 89 additions & 0 deletions tests/tools/test_file_write_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,95 @@ def test_safe_root_does_not_override_static_deny(self, tmp_path: Path, monkeypat
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)."""

Expand Down
2 changes: 1 addition & 1 deletion website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `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). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` 写入;超出范围的路径需要审批。 |
| `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` | 规范核心工具列表的逗号分隔覆盖(高级;极少需要)。 |
Expand Down
Loading