# NousResearch/hermes-agent · Safe-root vs credential-path write denial messaging

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #64347. Verify it addresses your case.
- **Recommended:** read PR #64347 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.54`: the share of member 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.

## Closed / duplicate issues

- #55607: \[Bug\]: safe-root write denials are reported as protected credential-path blocks
- #64003: Write-guard error message conflates "outside HERMES_WRITE_SAFE_ROOT" with "protected system/credential file" — agent misdiagnoses and retry-loops

## Prior & competing PRs

- **#64347: fix(file-safety): distinguish safe-root write denials from credential blocks**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `agent/copilot_acp_client.py`

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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]"
     
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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,
     )
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)}"
             )
    ```

    ```diff
    @@ -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 —
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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). |
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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` | 规范核心工具列表的逗号分隔覆盖（高级；极少需要）。 |
    ```

    ```diff
    @@ -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)）。
    +
     ## 界面
     
     | 变量 | 描述 |
    ```
- **#55615: fix(file-safety): distinguish safe-root denials from credential-path blocks**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#64347`, `#64347`, `#63198`
- **#64007: fix(file): distinguish safe-root write denials**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#64347`, `#63198`, `#64347`

## Fix status

- Merged to main in PR #64347 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-14`.
- Subsystem: `agent`
