Safe-root vs credential-path write denial messaging

NousResearch/hermes-agent · cluster 55607 · agent

Data as of

Nothing open on GitHub. Fix merged in #64347. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #64347. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items concern a bug where write denials caused by paths outside the safe-root boundary are incorrectly reported as protected credential-path blocks. The work focuses on distinguishing and clarifying these two separate error conditions.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #64347 fix(file-safety): distinguish safe-root write denials from credential blocks

    merged to main

    files: agent/copilot_acp_client.py, agent/file_safety.py, tests/agent/test_copilot_acp_client.py, tests/tools/test_file_write_safety.py, tools/file_operations.py, website/docs/reference/environment-variables.md, website/docs/user-guide/configuration.md, website/docs/user-guide/features/cron.md, website/docs/user-guide/security.md, website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md

    Show the diff
    • agent/copilot_acp_client.py
      @@ -26,7 +26,7 @@
           Function,
       )
       
      -from agent.file_safety import get_read_block_error, is_write_denied
      +from agent.file_safety import get_read_block_error, get_write_denied_error
       from agent.redact import redact_sensitive_text
       from tools.environments.local import hermes_subprocess_env
       
      @@ -727,10 +727,9 @@ def _handle_server_message(
               elif method == "fs/write_text_file":
                   try:
                       path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
      -                if is_write_denied(str(path)):
      -                    raise PermissionError(
      -                        f"Write denied: '{path}' is a protected system/credential file."
      -                    )
      +                denied = get_write_denied_error(str(path))
      +                if denied:
      +                    raise PermissionError(denied)
                       path.parent.mkdir(parents=True, exist_ok=True)
                       path.write_text(str(params.get("content") or ""))
                       response = {
    • agent/file_safety.py
      @@ -95,16 +95,16 @@ def get_safe_write_roots() -> set[str]:
           return roots
       
       
      -def is_write_denied(path: str) -> bool:
      -    """Return True if path is blocked by the write denylist or safe root."""
      +def _classify_write_denial(path: str) -> Optional[str]:
      +    """Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed."""
           home = os.path.realpath(os.path.expanduser("~"))
           resolved = os.path.realpath(os.path.expanduser(str(path)))
       
           if resolved in build_write_denied_paths(home):
      -        return True
      +        return "credential"
           for prefix in build_write_denied_prefixes(home):
               if resolved.startswith(prefix):
      -            return True
      +            return "credential"
       
           mcp_tokens_dir_name = "[redacted:secret-assignment]"
       
      @@ -121,13 +121,13 @@ def is_write_denied(path: str) -> bool:
               try:
                   mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
                   if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
      -                return True
      +                return "credential"
               except Exception:
                   pass
               try:
                   pairing_real = os.path.realpath(os.path.join(base_real, "pairing"))
                   if resolved == pairing_real or resolved.startswith(pairing_real + os.sep):
      -                return True
      +                return "credential"
               except Exception:
                   pass
       
      @@ -139,9 +139,28 @@ def is_write_denied(path: str) -> bool:
                       allowed = True
                       break
               if not allowed:
      -            return True
      +            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
      +
       
      -    return False
      +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 is None:
      +        return None
      +    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."
      +        )
      +    return f"{verb} denied: '{path}' is a protected system/credential file."
       
       
       # Common secret-bearing project-local environment file basenames.
    • tests/agent/test_copilot_acp_client.py
      @@ -209,7 +209,11 @@ def test_write_text_file_reuses_write_denylist(self) -> None:
                   target = home / ".ssh" / "id_rsa"
                   target.parent.mkdir(parents=True, exist_ok=True)
       
      -            with patch("agent.copilot_acp_client.is_write_denied", return_value=True, create=True):
      +            with patch(
      +                "agent.copilot_acp_client.get_write_denied_error",
      +                return_value="Write denied: protected",
      +                create=True,
      +            ):
                       response = self._dispatch(
                           {
                               "jsonrpc": "2.0",
      @@ -248,6 +252,7 @@ def test_write_text_file_respects_safe_root(self) -> None:
                       )
       
               self.assertIn("error", response)
      +        self.assertIn("HERMES_WRITE_SAFE_ROOT", str(response["error"]))
               self.assertFalse(outside.exists())
       
       
    • tests/tools/test_file_write_safety.py
      @@ -168,6 +168,107 @@ def test_duplicate_roots_deduplicated(self, tmp_path: Path, monkeypatch):
               assert _is_write_denied(str(inside)) is False
       
       
      +class TestGetWriteDeniedError:
      +    """get_write_denied_error() should distinguish credential vs safe-root blocks."""
      +
      +    def test_credential_path_message(self):
      +        from agent.file_safety import get_write_denied_error
      +
      +        err = get_write_denied_error(os.path.expanduser("~/.ssh/id_rsa"))
      +        assert err is not None
      +        assert "protected system/credential file" in err
      +        assert "HERMES_WRITE_SAFE_ROOT" not in err
      +
      +    def test_safe_root_message(self, tmp_path: Path, monkeypatch):
      +        from agent.file_safety import get_write_denied_error
      +
      +        safe_root = tmp_path / "workspace"
      +        outside = tmp_path / "outside.txt"
      +        os.makedirs(safe_root, exist_ok=True)
      +
      +        monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
      +        err = get_write_denied_error(str(outside))
      +        assert err is not None
      +        assert "outside HERMES_WRITE_SAFE_ROOT" in err
      +        assert str(safe_root) in err
      +        assert "protected system/credential file" not in err
      +
      +    def test_allowed_path_returns_none(self, tmp_path: Path):
      +        from agent.file_safety import get_write_denied_error
      +
      +        target = tmp_path / "ok.txt"
      +        assert get_write_denied_error(str(target)) is None
      +
      +
      +class TestSafeRootDenialMessageIntegration:
      +    """Regression tests verifying that file-tools surface the correct denial
      +    message when HERMES_WRITE_SAFE_ROOT blocks a path.
      +
      +    Prior to this fix, ALL write denials returned the same "protected
      +    system/credential file" message regardless of root cause.  These tests
      +    exercise the actual write_file / patch_replace code path, not just
      +    the get_write_denied_error() helper in isolation.
      +    """
      +
      +    @pytest.fixture
      +    def ops(self, tmp_path: Path):
      +        from tools.environments.local import LocalEnvironment
      +        from tools.file_operations import ShellFileOperations
      +        env = LocalEnvironment(cwd=str(tmp_path))
      +        return ShellFileOperations(env, cwd=str(tmp_path))
      +
      +    def test_write_file_safe_root_outside_shows_safe_root_message(
      +        self, ops, tmp_path: Path, monkeypatch
      +    ):
      +        safe_root = tmp_path / "workspace"
      +        safe_root.mkdir()
      +        outside = tmp_path / "other" / "file.txt"
      +        outside.parent.mkdir()
      +        monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
      +
      +        res = ops.write_file(str(outside), "content")
      +        assert res.error is not None
      +        assert "outside HERMES_WRITE_SAFE_ROOT" in res.error
      +        assert str(safe_root) in res.error
      +        assert "credential" not in res.error
      +        assert not outside.exists()
      +
      +    def test_patch_replace_safe_root_outside_shows_safe_root_message(
      +        self, ops, tmp_path: Path, monkeypatch
      +    ):
      +        safe_root = tmp_path / "workspace"
      +        safe_root.mkdir()
      +        outside = tmp_path / "other" / "file.txt"
      +        outside.parent.mkdir()
      +        outside.write_text("old content")
      +        monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
      +
      +        res = ops.patch_replace(str(outside), "old", "new")
      +        assert res.error is not None
      +        assert "outside HERMES_WRITE_SAFE_ROOT" in res.error
      +        assert "credential" not in res.error
      +
      +    def test_write_file_credential_path_shows_credential_message(
      +        self, ops, tmp_path: Path
      +    ):
      +        res = ops.write_file("/etc/shadow", "content")
      +        assert res.error is not None
      +        assert "protected system/credential file" in res.error
      +        assert "outside" not in res.error
      +
      +    def test_write_file_allowed_path_returns_no_error(
      +        self, ops, tmp_path: Path, monkeypatch
      +    ):
      +        safe_root = tmp_path / "workspace"
      +        safe_root.mkdir()
      +        inside = safe_root / "file.txt"
      +        monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root))
      +
      +        res = ops.write_file(str(inside), "content")
      +        assert res.error is None
      +        assert inside.read_text() == "content"
      +
      +
       class TestCheckSensitivePathMacOSBypass:
           """Verify _check_sensitive_path blocks /private/etc paths (issue #8734)."""
       
    • tools/file_operations.py
      @@ -37,6 +37,7 @@
       from agent.file_safety import (
           build_write_denied_paths,
           build_write_denied_prefixes,
      +    get_write_denied_error,
           is_write_denied as _shared_is_write_denied,
       )
       
      @@ -1289,8 +1290,9 @@ def delete_path(self, path: str, recursive: bool = False) -> WriteResult:
       
           def _python_delete(self, path: str, recursive: bool) -> WriteResult:
               path = self._expand_path(path)
      -        if _is_write_denied(path):
      -            return WriteResult(error=f"Delete denied: {path} is a protected path")
      +        denied = get_write_denied_error(path, verb="Delete")
      +        if denied:
      +            return WriteResult(error=denied)
       
               # We can't shell out to ``rm`` here — it doesn't exist on Windows
               # ``cmd.exe`` or PowerShell, so this code path is what's left when
      @@ -1335,8 +1337,9 @@ def move_file(self, src: str, dst: str) -> WriteResult:
               src = self._expand_path(src)
               dst = self._expand_path(dst)
               for p in (src, dst):
      -            if _is_write_denied(p):
      -                return WriteResult(error=f"Move denied: {p} is a protected path")
      +            denied = get_write_denied_error(p, verb="Move")
      +            if denied:
      +                return WriteResult(error=denied)
               result = self._exec(
                   f"mv {self._escape_shell_arg(src)} {self._escape_shell_arg(dst)}"
               )
      @@ -1383,8 +1386,9 @@ def write_file(self, path: str, content: str) -> WriteResult:
               path = self._expand_path(path)
       
               # Block writes to sensitive paths
      -        if _is_write_denied(path):
      -            return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.")
      +        denied = get_write_denied_error(path)
      +        if denied:
      +            return WriteResult(error=denied)
       
               # ── Fail-closed pre-write syntax gate ───────────────────────────
               # Validate the CANDIDATE content BEFORE any bytes touch disk —
      @@ -1566,8 +1570,9 @@ def patch_replace(self, path: str, old_string: str, new_string: str,
               path = self._expand_path(path)
       
               # Block writes to sensitive paths
      -        if _is_write_denied(path):
      -            return PatchResult(error=f"Write denied: '{path}' is a protected system/credential file.")
      +        denied = get_write_denied_error(path)
      +        if denied:
      +            return PatchResult(error=denied)
       
               # Read current content
               read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null"
    • website/docs/reference/environment-variables.md
      @@ -745,7 +745,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 `os.pathsep` (`:` on Unix, `;` on Windows). |
      +| `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that **hard-blocks** `write_file`/`patch` writes outside the listed roots (no approval prompt). Supports multiple directories separated by `os.pathsep` (`:` on Unix, `;` on Windows). See [HERMES_WRITE_SAFE_ROOT](#hermes_write_safe_root) below. |
       | `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). |
      @@ -760,6 +760,22 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us
       | `HERMES_AGENT_LOGO` | Override the ASCII banner logo at CLI startup. |
       | `DELEGATION_MAX_CONCURRENT_CHILDREN` | Max parallel subagents per `delegate_task` batch (default: `3`, floor of 1, no ceiling). Also configurable via `delegation.max_concurrent_children` in `config.yaml` — the config value takes priority. |
       
      +### HERMES_WRITE_SAFE_ROOT {#hermes_write_safe_root}
      +
      +When this variable is set, `write_file` and `patch` may only target paths inside the listed directory prefix(es). Any path outside those roots is **rejected immediately** — the write does not go through the dangerous-command approval system and there is no prompt to override it.
      +
      +The official Docker image sets `HERMES_WRITE_SAFE_ROOT=/opt/data` alongside `HERMES_HOME=/opt/data` so the agent cannot escape the mounted data volume.
      +
      +**Do not add this to `~/.hermes/.env` unless you intend to sandbox writes.** A common mistake is pointing it at a project directory while expecting the agent to edit `~/.hermes/cron/jobs.json`, `~/.hermes/skills/`, or scripts under a profile — those paths are outside the sandbox and every `write_file`/`patch` to them fails with an `outside HERMES_WRITE_SAFE_ROOT` error.
      +
      +To allow both a workspace and Hermes state, list both prefixes (order does not matter):
      +
      +```bash
      +export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes
      +```
      +
      +Unset the variable or remove it from `.env` to restore normal writes (still subject to the credential-path denylist — see [File write safety](../user-guide/security.md#file-write-safety)).
      +
       ## Interface
       
       | Variable | Description |
    • website/docs/user-guide/configuration.md
      @@ -1447,6 +1447,22 @@ Example footer:
       
       Set `file_mutation_verifier: false` (or `HERMES_FILE_MUTATION_VERIFIER=0`) to suppress the footer. The verifier only fires when real failures are outstanding at turn end — a model that retries a failed patch and succeeds within the same turn will not trigger it for that file.
       
      +**Trust the verifier over the model's summary.** The footer means the listed files were **not** modified on disk, even if the assistant's closing message says the task is done. Common causes:
      +
      +- **Write denied** — path is on the credential denylist or outside `HERMES_WRITE_SAFE_ROOT` (see [File write safety](./security.md#file-write-safety))
      +- **Patch mismatch** — `old_string` did not match the file on disk
      +- **Syntax gate** — candidate content failed JSON/YAML/TOML validation before write
      +
      +Example footer when writes are blocked:
      +
      +```
      +⚠️ File-mutation verifier: 2 file(s) were NOT modified this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm.
      +  • ~/.hermes/cron/jobs.json — [patch] Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (/path/to/project)
      +  • ~/.hermes/scripts/monitor.py — [write_file] Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (/path/to/project)
      +```
      +
      +If writes to Hermes state (cron jobs, skills, scripts under `~/.hermes/`) are failing, check whether `HERMES_WRITE_SAFE_ROOT` is set in your environment. For cron changes, use the `cronjob` tool or `hermes cron edit` instead of patching `jobs.json` directly.
      +
       ### UI language for static messages
       
       The `display.language` setting translates a small set of static user-facing messages — the CLI approval prompt, a handful of gateway slash-command replies (e.g. restart-drain notices, "approval expired", "goal cleared"). It does **not** translate agent responses, log lines, tool output, error tracebacks, or slash-command descriptions — those stay in English. If you want the agent itself to reply in another language, just tell it in your prompt or system message.
    • website/docs/user-guide/features/cron.md
      @@ -732,6 +732,10 @@ The referenced jobs' most recent completed outputs are injected above the prompt
       
       Jobs are stored in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`.
       
      +:::tip
      +Ask the agent to manage jobs through the `cronjob` tool, `hermes cron edit`, or `/cron` — not by patching `jobs.json` directly. Direct edits can fail silently when [file write safety](../security.md#file-write-safety) blocks the path (for example when `HERMES_WRITE_SAFE_ROOT` is set), and the [file-mutation verifier](../configuration.md#file-mutation-verifier) footer is the authoritative signal that nothing was saved.
      +:::
      +
       Jobs may store `model` and `provider` as `null`. When those fields are omitted, Hermes resolves them at execution time from the global configuration. They only appear in the job record when a per-job override is set.
       
       The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.
    • website/docs/user-guide/security.md
      @@ -10,15 +10,16 @@ Hermes Agent is designed with a defense-in-depth security model. This page cover
       
       ## Overview
       
      -The security model has seven layers:
      +The security model has eight layers:
       
       1. **User authorization** — who can talk to the agent (allowlists, DM pairing)
       2. **Dangerous command approval** — human-in-the-loop for destructive operations
      -3. **Container isolation** — Docker/Singularity/Modal sandboxing with hardened settings
      -4. **MCP credential filtering** — environment variable isolation for MCP subprocesses
      -5. **Context file scanning** — prompt injection detection in project files
      -6. **Cross-session isolation** — sessions cannot access each other's data or state; cron job storage paths are hardened against path traversal attacks
      -7. **Input sanitization** — working directory parameters in terminal tool backends are validated against an allowlist to prevent shell injection
      +3. **File write safety** — denylist and optional write sandbox for `write_file`/`patch`
      +4. **Container isolation** — Docker/Singularity/Modal sandboxing with hardened settings
      +5. **MCP credential filtering** — environment variable isolation for MCP subprocesses
      +6. **Context file scanning** — prompt injection detection in project files
      +7. **Cross-session isolation** — sessions cannot access each other's data or state; cron job storage paths are hardened against path traversal attacks
      +8. **Input sanitization** — working directory parameters in terminal tool backends are validated against an allowlist to prevent shell injection
       
       ## Dangerous Command Approval
       
      @@ -232,6 +233,48 @@ These patterns are loaded at startup and silently approved in all future session
       Use `hermes config edit` to review or remove patterns from your permanent allowlist.
       :::
       
      +## File Write Safety {#file-write-safety}
      +
      +Before `write_file` or `patch` touches disk, Hermes checks the target path against a denylist and an optional sandbox. Blocked writes return an error to the agent immediately — **there is no approval prompt** and no way to override from the chat UI. The model may still claim the edit succeeded; when `display.file_mutation_verifier` is on (default), trust the [file-mutation verifier footer](./configuration.md#file-mutation-verifier) over the assistant's closing summary.
      +
      +### Protected paths (always blocked)
      +
      +These categories are always denied, even when `HERMES_WRITE_SAFE_ROOT` is unset:
      +
      +| Category | Examples |
      +|----------|----------|
      +| OS credential stores | `~/.ssh/`, `~/.aws/`, `~/.kube/`, `/etc/sudoers`, `~/.netrc` |
      +| Hermes credential stores | `auth.json`, `.env`, `.anthropic_oauth.json`, `mcp-tokens/`, `pairing/` under HERMES_HOME (active profile and global root) |
      +| Project secret files | `.env`, `.env.local`, `.env.production`, `.envrc` anywhere on disk |
      +
      +Sensitive paths inside the safe root are still blocked — pointing `HERMES_WRITE_SAFE_ROOT` at `$HOME` does not allow writing `~/.ssh/id_rsa`.
      +
      +Safe-root violations return `Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (…)`. Credential-path blocks use `Write denied: '…' is a protected system/credential file.`
      +
      +### HERMES_WRITE_SAFE_ROOT (optional sandbox)
      +
      +When set, `write_file` and `patch` may only target paths inside the listed directory prefix(es). Anything outside is **hard-blocked** — not routed through dangerous-command approval.
      +
      +- Set automatically in the [official Docker image](https://github.com/NousResearch/hermes-agent) (`HERMES_WRITE_SAFE_ROOT=/opt/data`)
      +- Supports multiple roots separated by `:` on Unix or `;` on Windows
      +- **Do not add to `~/.hermes/.env` casually.** If you set it to a project directory, the agent cannot write to `~/.hermes/cron/jobs.json`, profile skills, or other Hermes state outside that prefix
      +
      +To allow both a workspace and Hermes home:
      +
      +```bash
      +export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes
      +```
      +
      +Unset the variable to restore unrestricted writes (subject to the protected-path denylist). Full reference: [HERMES_WRITE_SAFE_ROOT](../reference/environment-variables.md#hermes_write_safe_root).
      +
      +### Cron and other Hermes state
      +
      +Do not ask the agent to `patch` `~/.hermes/cron/jobs.json` directly. Use the `cronjob` tool, [`hermes cron`](./features/cron.md), or `/cron` — they update the job store through the supported API. The same applies to other Hermes control files when write safety blocks direct edits.
      +
      +:::note Defense-in-depth, not a hard boundary
      +Write guards apply to `write_file` and `patch` only. The `terminal` tool runs as the same OS user and can still `cat` or overwrite denied paths via shell commands. The denylist reduces accidental damage and gives models a clear stop signal; it does not sandbox a hostile or compromised agent.
      +:::
      +
       ## User Authorization (Gateway)
       
       When running the messaging gateway, Hermes controls who can interact with the bot through a layered authorization system.
    • website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md
      @@ -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` 写入;超出范围的路径需要审批。支持多个目录,使用 `os.pathsep` 分隔(Unix 为 `:`,Windows 为 `;`)。 |
      +| `HERMES_WRITE_SAFE_ROOT` | 可选目录前缀,**硬阻止** `write_file`/`patch` 写入列出的根目录之外的路径(无审批提示)。支持多个目录,使用 `os.pathsep` 分隔(Unix 为 `:`,Windows 为 `;`)。详见下方 [HERMES_WRITE_SAFE_ROOT](#hermes_write_safe_root)。 |
       | `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` | 规范核心工具列表的逗号分隔覆盖(高级;极少需要)。 |
      @@ -574,6 +574,22 @@ Graph 事件(Teams 会议、日历、聊天等)的入站变更通知监听
       | `HERMES_AGENT_LOGO` | 覆盖 CLI 启动时的 ASCII 横幅 logo。 |
       | `DELEGATION_MAX_CONCURRENT_CHILDREN` | 每个 `delegate_task` 批次的最大并行子 agent 数(默认:`3`,下限为 1,无上限)。也可通过 `config.yaml` 中的 `delegation.max_concurrent_children` 配置——config 值优先。 |
       
      +### HERMES_WRITE_SAFE_ROOT {#hermes_write_safe_root}
      +
      +设置此变量后,`write_file` 和 `patch` 只能写入列出的目录前缀内的路径。超出这些根目录的路径会被**立即拒绝**——不会进入危险命令审批流程,也没有聊天界面可以覆盖。
      +
      +官方 Docker 镜像会设置 `HERMES_WRITE_SAFE_ROOT=/opt/data` 与 `HERMES_HOME=/opt/data`,防止 agent 逃出挂载的数据卷。
      +
      +**除非有意沙箱化写入,否则不要将此变量加入 `~/.hermes/.env`。** 常见错误是将其指向项目目录,却期望 agent 编辑 `~/.hermes/cron/jobs.json`、`~/.hermes/skills/` 或 profile 下的脚本——这些路径在沙箱外,每次 `write_file`/`patch` 都会失败并返回 `outside HERMES_WRITE_SAFE_ROOT` 错误。
      +
      +若需同时允许工作区和 Hermes 状态目录,列出两个前缀(顺序无关):
      +
      +```bash
      +export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes
      +```
      +
      +取消设置或从 `.env` 中移除此变量可恢复常规写入(仍受凭证路径拒绝列表约束——见[文件写入安全](../user-guide/security.md#file-write-safety))。
      +
       ## 界面
       
       | 变量 | 描述 |

2 PRs closed without merging

From the close comments: merged via another change 2

  • #55615 fix(file-safety): distinguish safe-root denials from credential-path blocks · merged via another change → #64347, #63198
  • #64007 fix(file): distinguish safe-root write denials · merged via another change → #64347, #63198
Closed issues · 2
  • Closed#55607[Bug]: safe-root write denials are reported as protected credential-path blocks
  • Closed#64003Write-guard error message conflates "outside HERMES_WRITE_SAFE_ROOT" with "protected system/credential file" — agent misdiagnoses and retry-loops
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.54: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#64347
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
14 Jul 2026

For your agent

The same facts, typed.