{"repo":"NousResearch/hermes-agent","public_id":"55607","display_label":"Safe-root vs credential-path write denial messaging","summary":"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.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64347},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-14T11:39:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.536},"fix_edges":[{"issue_number":55607,"pr_number":64347,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":64003,"pr_number":64347,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":55607,"title":"[Bug]: safe-root write denials are reported as protected credential-path blocks","url":null,"state":"closed"},{"kind":"issue","number":64003,"title":"Write-guard error message conflates \"outside HERMES_WRITE_SAFE_ROOT\" with \"protected system/credential file\" — agent misdiagnoses and retry-loops","url":null,"state":"closed"},{"kind":"pr","number":55615,"title":"fix(file-safety): distinguish safe-root denials from credential-path blocks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64347","number":64347},{"kind":"pr","value":"#64347","number":64347},{"kind":"pr","value":"#63198","number":63198}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64007,"title":"fix(file): distinguish safe-root write denials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64347","number":64347},{"kind":"pr","value":"#63198","number":63198},{"kind":"pr","value":"#64347","number":64347}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64347,"title":"fix(file-safety): distinguish safe-root write denials from credential blocks","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/copilot_acp_client.py","hunks":["@@ -26,7 +26,7 @@\n     Function,\n )\n \n-from agent.file_safety import get_read_block_error, is_write_denied\n+from agent.file_safety import get_read_block_error, get_write_denied_error\n from agent.redact import redact_sensitive_text\n from tools.environments.local import hermes_subprocess_env\n ","@@ -727,10 +727,9 @@ def _handle_server_message(\n         elif method == \"fs/write_text_file\":\n             try:\n                 path = _ensure_path_within_cwd(str(params.get(\"path\") or \"\"), cwd)\n-                if is_write_denied(str(path)):\n-                    raise PermissionError(\n-                        f\"Write denied: '{path}' is a protected system/credential file.\"\n-                    )\n+                denied = get_write_denied_error(str(path))\n+                if denied:\n+                    raise PermissionError(denied)\n                 path.parent.mkdir(parents=True, exist_ok=True)\n                 path.write_text(str(params.get(\"content\") or \"\"))\n                 response = {"]},{"file":"agent/file_safety.py","hunks":["@@ -95,16 +95,16 @@ def get_safe_write_roots() -> set[str]:\n     return roots\n \n \n-def is_write_denied(path: str) -> bool:\n-    \"\"\"Return True if path is blocked by the write denylist or safe root.\"\"\"\n+def _classify_write_denial(path: str) -> Optional[str]:\n+    \"\"\"Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed.\"\"\"\n     home = os.path.realpath(os.path.expanduser(\"~\"))\n     resolved = os.path.realpath(os.path.expanduser(str(path)))\n \n     if resolved in build_write_denied_paths(home):\n-        return True\n+        return \"credential\"\n     for prefix in build_write_denied_prefixes(home):\n         if resolved.startswith(prefix):\n-            return True\n+            return \"credential\"\n \n     mcp_tokens_dir_name = \"[redacted:secret-assignment]\"\n ","@@ -121,13 +121,13 @@ def is_write_denied(path: str) -> bool:\n         try:\n             mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))\n             if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):\n-                return True\n+                return \"credential\"\n         except Exception:\n             pass\n         try:\n             pairing_real = os.path.realpath(os.path.join(base_real, \"pairing\"))\n             if resolved == pairing_real or resolved.startswith(pairing_real + os.sep):\n-                return True\n+                return \"credential\"\n         except Exception:\n             pass\n ","@@ -139,9 +139,28 @@ def is_write_denied(path: str) -> bool:\n                 allowed = True\n                 break\n         if not allowed:\n-            return True\n+            return \"safe_root\"\n+\n+    return None\n+\n+\n+def is_write_denied(path: str) -> bool:\n+    \"\"\"Return True if path is blocked by the write denylist or safe root.\"\"\"\n+    return _classify_write_denial(path) is not None\n+\n \n-    return False\n+def get_write_denied_error(path: str, *, verb: str = \"Write\") -> Optional[str]:\n+    \"\"\"Return a user/model-facing error when writes to ``path`` are blocked.\"\"\"\n+    denial = _classify_write_denial(path)\n+    if denial is None:\n+        return None\n+    if denial == \"safe_root\":\n+        roots_display = os.pathsep.join(sorted(get_safe_write_roots()))\n+        return (\n+            f\"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT \"\n+            f\"({roots_display}). Unset the variable or add this path's directory prefix.\"\n+        )\n+    return f\"{verb} denied: '{path}' is a protected system/credential file.\"\n \n \n # Common secret-bearing project-local environment file basenames."]},{"file":"tests/agent/test_copilot_acp_client.py","hunks":["@@ -209,7 +209,11 @@ def test_write_text_file_reuses_write_denylist(self) -> None:\n             target = home / \".ssh\" / \"id_rsa\"\n             target.parent.mkdir(parents=True, exist_ok=True)\n \n-            with patch(\"agent.copilot_acp_client.is_write_denied\", return_value=True, create=True):\n+            with patch(\n+                \"agent.copilot_acp_client.get_write_denied_error\",\n+                return_value=\"Write denied: protected\",\n+                create=True,\n+            ):\n                 response = self._dispatch(\n                     {\n                         \"jsonrpc\": \"2.0\",","@@ -248,6 +252,7 @@ def test_write_text_file_respects_safe_root(self) -> None:\n                 )\n \n         self.assertIn(\"error\", response)\n+        self.assertIn(\"HERMES_WRITE_SAFE_ROOT\", str(response[\"error\"]))\n         self.assertFalse(outside.exists())\n \n "]},{"file":"tests/tools/test_file_write_safety.py","hunks":["@@ -168,6 +168,107 @@ def test_duplicate_roots_deduplicated(self, tmp_path: Path, monkeypatch):\n         assert _is_write_denied(str(inside)) is False\n \n \n+class TestGetWriteDeniedError:\n+    \"\"\"get_write_denied_error() should distinguish credential vs safe-root blocks.\"\"\"\n+\n+    def test_credential_path_message(self):\n+        from agent.file_safety import get_write_denied_error\n+\n+        err = get_write_denied_error(os.path.expanduser(\"~/.ssh/id_rsa\"))\n+        assert err is not None\n+        assert \"protected system/credential file\" in err\n+        assert \"HERMES_WRITE_SAFE_ROOT\" not in err\n+\n+    def test_safe_root_message(self, tmp_path: Path, monkeypatch):\n+        from agent.file_safety import get_write_denied_error\n+\n+        safe_root = tmp_path / \"workspace\"\n+        outside = tmp_path / \"outside.txt\"\n+        os.makedirs(safe_root, exist_ok=True)\n+\n+        monkeypatch.setenv(\"HERMES_WRITE_SAFE_ROOT\", str(safe_root))\n+        err = get_write_denied_error(str(outside))\n+        assert err is not None\n+        assert \"outside HERMES_WRITE_SAFE_ROOT\" in err\n+        assert str(safe_root) in err\n+        assert \"protected system/credential file\" not in err\n+\n+    def test_allowed_path_returns_none(self, tmp_path: Path):\n+        from agent.file_safety import get_write_denied_error\n+\n+        target = tmp_path / \"ok.txt\"\n+        assert get_write_denied_error(str(target)) is None\n+\n+\n+class TestSafeRootDenialMessageIntegration:\n+    \"\"\"Regression tests verifying that file-tools surface the correct denial\n+    message when HERMES_WRITE_SAFE_ROOT blocks a path.\n+\n+    Prior to this fix, ALL write denials returned the same \"protected\n+    system/credential file\" message regardless of root cause.  These tests\n+    exercise the actual write_file / patch_replace code path, not just\n+    the get_write_denied_error() helper in isolation.\n+    \"\"\"\n+\n+    @pytest.fixture\n+    def ops(self, tmp_path: Path):\n+        from tools.environments.local import LocalEnvironment\n+        from tools.file_operations import ShellFileOperations\n+        env = LocalEnvironment(cwd=str(tmp_path))\n+        return ShellFileOperations(env, cwd=str(tmp_path))\n+\n+    def test_write_file_safe_root_outside_shows_safe_root_message(\n+        self, ops, tmp_path: Path, monkeypatch\n+    ):\n+        safe_root = tmp_path / \"workspace\"\n+        safe_root.mkdir()\n+        outside = tmp_path / \"other\" / \"file.txt\"\n+        outside.parent.mkdir()\n+        monkeypatch.setenv(\"HERMES_WRITE_SAFE_ROOT\", str(safe_root))\n+\n+        res = ops.write_file(str(outside), \"content\")\n+        assert res.error is not None\n+        assert \"outside HERMES_WRITE_SAFE_ROOT\" in res.error\n+        assert str(safe_root) in res.error\n+        assert \"credential\" not in res.error\n+        assert not outside.exists()\n+\n+    def test_patch_replace_safe_root_outside_shows_safe_root_message(\n+        self, ops, tmp_path: Path, monkeypatch\n+    ):\n+        safe_root = tmp_path / \"workspace\"\n+        safe_root.mkdir()\n+        outside = tmp_path / \"other\" / \"file.txt\"\n+        outside.parent.mkdir()\n+        outside.write_text(\"old content\")\n+        monkeypatch.setenv(\"HERMES_WRITE_SAFE_ROOT\", str(safe_root))\n+\n+        res = ops.patch_replace(str(outside), \"old\", \"new\")\n+        assert res.error is not None\n+        assert \"outside HERMES_WRITE_SAFE_ROOT\" in res.error\n+        assert \"credential\" not in res.error\n+\n+    def test_write_file_credential_path_shows_credential_message(\n+        self, ops, tmp_path: Path\n+    ):\n+        res = ops.write_file(\"/etc/shadow\", \"content\")\n+        assert res.error is not None\n+        assert \"protected system/credential file\" in res.error\n+        assert \"outside\" not in res.error\n+\n+    def test_write_file_allowed_path_returns_no_error(\n+        self, ops, tmp_path: Path, monkeypatch\n+    ):\n+        safe_root = tmp_path / \"workspace\"\n+        safe_root.mkdir()\n+        inside = safe_root / \"file.txt\"\n+        monkeypatch.setenv(\"HERMES_WRITE_SAFE_ROOT\", str(safe_root))\n+\n+        res = ops.write_file(str(inside), \"content\")\n+        assert res.error is None\n+        assert inside.read_text() == \"content\"\n+\n+\n class TestCheckSensitivePathMacOSBypass:\n     \"\"\"Verify _check_sensitive_path blocks /private/etc paths (issue #8734).\"\"\"\n "]},{"file":"tools/file_operations.py","hunks":["@@ -37,6 +37,7 @@\n from agent.file_safety import (\n     build_write_denied_paths,\n     build_write_denied_prefixes,\n+    get_write_denied_error,\n     is_write_denied as _shared_is_write_denied,\n )\n ","@@ -1289,8 +1290,9 @@ def delete_path(self, path: str, recursive: bool = False) -> WriteResult:\n \n     def _python_delete(self, path: str, recursive: bool) -> WriteResult:\n         path = self._expand_path(path)\n-        if _is_write_denied(path):\n-            return WriteResult(error=f\"Delete denied: {path} is a protected path\")\n+        denied = get_write_denied_error(path, verb=\"Delete\")\n+        if denied:\n+            return WriteResult(error=denied)\n \n         # We can't shell out to ``rm`` here — it doesn't exist on Windows\n         # ``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:\n         src = self._expand_path(src)\n         dst = self._expand_path(dst)\n         for p in (src, dst):\n-            if _is_write_denied(p):\n-                return WriteResult(error=f\"Move denied: {p} is a protected path\")\n+            denied = get_write_denied_error(p, verb=\"Move\")\n+            if denied:\n+                return WriteResult(error=denied)\n         result = self._exec(\n             f\"mv {self._escape_shell_arg(src)} {self._escape_shell_arg(dst)}\"\n         )","@@ -1383,8 +1386,9 @@ def write_file(self, path: str, content: str) -> WriteResult:\n         path = self._expand_path(path)\n \n         # Block writes to sensitive paths\n-        if _is_write_denied(path):\n-            return WriteResult(error=f\"Write denied: '{path}' is a protected system/credential file.\")\n+        denied = get_write_denied_error(path)\n+        if denied:\n+            return WriteResult(error=denied)\n \n         # ── Fail-closed pre-write syntax gate ───────────────────────────\n         # 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,\n         path = self._expand_path(path)\n \n         # Block writes to sensitive paths\n-        if _is_write_denied(path):\n-            return PatchResult(error=f\"Write denied: '{path}' is a protected system/credential file.\")\n+        denied = get_write_denied_error(path)\n+        if denied:\n+            return PatchResult(error=denied)\n \n         # Read current content\n         read_cmd = f\"cat {self._escape_shell_arg(path)} 2>/dev/null\""]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -745,7 +745,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us\n | `HERMES_PREFILL_MESSAGES_FILE` | Path to a JSON file of ephemeral prefill messages injected at API-call time. |\n | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. |\n | `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). |\n-| `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). |\n+| `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. |\n | `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`. |\n | `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the \"file changed since you read it\" guard on `patch`/`write_file`. |\n | `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\n | `HERMES_AGENT_LOGO` | Override the ASCII banner logo at CLI startup. |\n | `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. |\n \n+### HERMES_WRITE_SAFE_ROOT {#hermes_write_safe_root}\n+\n+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.\n+\n+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.\n+\n+**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.\n+\n+To allow both a workspace and Hermes state, list both prefixes (order does not matter):\n+\n+```bash\n+export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes\n+```\n+\n+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)).\n+\n ## Interface\n \n | Variable | Description |"]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -1447,6 +1447,22 @@ Example footer:\n \n 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.\n \n+**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:\n+\n+- **Write denied** — path is on the credential denylist or outside `HERMES_WRITE_SAFE_ROOT` (see [File write safety](./security.md#file-write-safety))\n+- **Patch mismatch** — `old_string` did not match the file on disk\n+- **Syntax gate** — candidate content failed JSON/YAML/TOML validation before write\n+\n+Example footer when writes are blocked:\n+\n+```\n+⚠️ 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.\n+  • ~/.hermes/cron/jobs.json — [patch] Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (/path/to/project)\n+  • ~/.hermes/scripts/monitor.py — [write_file] Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (/path/to/project)\n+```\n+\n+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.\n+\n ### UI language for static messages\n \n 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."]},{"file":"website/docs/user-guide/features/cron.md","hunks":["@@ -732,6 +732,10 @@ The referenced jobs' most recent completed outputs are injected above the prompt\n \n Jobs are stored in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`.\n \n+:::tip\n+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.\n+:::\n+\n 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.\n \n The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind."]},{"file":"website/docs/user-guide/security.md","hunks":["@@ -10,15 +10,16 @@ Hermes Agent is designed with a defense-in-depth security model. This page cover\n \n ## Overview\n \n-The security model has seven layers:\n+The security model has eight layers:\n \n 1. **User authorization** — who can talk to the agent (allowlists, DM pairing)\n 2. **Dangerous command approval** — human-in-the-loop for destructive operations\n-3. **Container isolation** — Docker/Singularity/Modal sandboxing with hardened settings\n-4. **MCP credential filtering** — environment variable isolation for MCP subprocesses\n-5. **Context file scanning** — prompt injection detection in project files\n-6. **Cross-session isolation** — sessions cannot access each other's data or state; cron job storage paths are hardened against path traversal attacks\n-7. **Input sanitization** — working directory parameters in terminal tool backends are validated against an allowlist to prevent shell injection\n+3. **File write safety** — denylist and optional write sandbox for `write_file`/`patch`\n+4. **Container isolation** — Docker/Singularity/Modal sandboxing with hardened settings\n+5. **MCP credential filtering** — environment variable isolation for MCP subprocesses\n+6. **Context file scanning** — prompt injection detection in project files\n+7. **Cross-session isolation** — sessions cannot access each other's data or state; cron job storage paths are hardened against path traversal attacks\n+8. **Input sanitization** — working directory parameters in terminal tool backends are validated against an allowlist to prevent shell injection\n \n ## Dangerous Command Approval\n ","@@ -232,6 +233,48 @@ These patterns are loaded at startup and silently approved in all future session\n Use `hermes config edit` to review or remove patterns from your permanent allowlist.\n :::\n \n+## File Write Safety {#file-write-safety}\n+\n+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.\n+\n+### Protected paths (always blocked)\n+\n+These categories are always denied, even when `HERMES_WRITE_SAFE_ROOT` is unset:\n+\n+| Category | Examples |\n+|----------|----------|\n+| OS credential stores | `~/.ssh/`, `~/.aws/`, `~/.kube/`, `/etc/sudoers`, `~/.netrc` |\n+| Hermes credential stores | `auth.json`, `.env`, `.anthropic_oauth.json`, `mcp-tokens/`, `pairing/` under HERMES_HOME (active profile and global root) |\n+| Project secret files | `.env`, `.env.local`, `.env.production`, `.envrc` anywhere on disk |\n+\n+Sensitive paths inside the safe root are still blocked — pointing `HERMES_WRITE_SAFE_ROOT` at `$HOME` does not allow writing `~/.ssh/id_rsa`.\n+\n+Safe-root violations return `Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (…)`. Credential-path blocks use `Write denied: '…' is a protected system/credential file.`\n+\n+### HERMES_WRITE_SAFE_ROOT (optional sandbox)\n+\n+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.\n+\n+- Set automatically in the [official Docker image](https://github.com/NousResearch/hermes-agent) (`HERMES_WRITE_SAFE_ROOT=/opt/data`)\n+- Supports multiple roots separated by `:` on Unix or `;` on Windows\n+- **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\n+\n+To allow both a workspace and Hermes home:\n+\n+```bash\n+export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes\n+```\n+\n+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).\n+\n+### Cron and other Hermes state\n+\n+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.\n+\n+:::note Defense-in-depth, not a hard boundary\n+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.\n+:::\n+\n ## User Authorization (Gateway)\n \n When running the messaging gateway, Hermes controls who can interact with the bot through a layered authorization system."]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md","hunks":["@@ -559,7 +559,7 @@ Graph 事件（Teams 会议、日历、聊天等）的入站变更通知监听\n | `HERMES_PREFILL_MESSAGES_FILE` | 包含在 API 调用时注入的临时预填消息的 JSON 文件路径。 |\n | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false`——允许工具获取 localhost/私有网络 URL。gateway 模式下默认关闭。 |\n | `HERMES_REDACT_SECRETS` | `true`/`false`——控制工具输出、日志和聊天响应中的密钥脱敏（默认：`true`）。 |\n-| `HERMES_WRITE_SAFE_ROOT` | 可选目录前缀，限制 `write_file`/`patch` 写入；超出范围的路径需要审批。支持多个目录，使用 `os.pathsep` 分隔（Unix 为 `:`，Windows 为 `;`）。 |\n+| `HERMES_WRITE_SAFE_ROOT` | 可选目录前缀，**硬阻止** `write_file`/`patch` 写入列出的根目录之外的路径（无审批提示）。支持多个目录，使用 `os.pathsep` 分隔（Unix 为 `:`，Windows 为 `;`）。详见下方 [HERMES_WRITE_SAFE_ROOT](#hermes_write_safe_root)。 |\n | `HERMES_DISABLE_LAZY_INSTALLS` | 官方 Docker 镜像中自动设置的内部桥接变量，用于阻止运行时将依赖安装到不可变的 `/opt/hermes` 树。面向用户的等价配置是 `config.yaml` 中的 `security.allow_lazy_installs: false`；不要在 `.env` 中手动设置此变量。 |\n | `HERMES_DISABLE_FILE_STATE_GUARD` | 设为 `1` 可关闭 `patch`/`write_file` 上的\"文件自上次读取后已更改\"保护。 |\n | `HERMES_CORE_TOOLS` | 规范核心工具列表的逗号分隔覆盖（高级；极少需要）。 |","@@ -574,6 +574,22 @@ Graph 事件（Teams 会议、日历、聊天等）的入站变更通知监听\n | `HERMES_AGENT_LOGO` | 覆盖 CLI 启动时的 ASCII 横幅 logo。 |\n | `DELEGATION_MAX_CONCURRENT_CHILDREN` | 每个 `delegate_task` 批次的最大并行子 agent 数（默认：`3`，下限为 1，无上限）。也可通过 `config.yaml` 中的 `delegation.max_concurrent_children` 配置——config 值优先。 |\n \n+### HERMES_WRITE_SAFE_ROOT {#hermes_write_safe_root}\n+\n+设置此变量后，`write_file` 和 `patch` 只能写入列出的目录前缀内的路径。超出这些根目录的路径会被**立即拒绝**——不会进入危险命令审批流程，也没有聊天界面可以覆盖。\n+\n+官方 Docker 镜像会设置 `HERMES_WRITE_SAFE_ROOT=/opt/data` 与 `HERMES_HOME=/opt/data`，防止 agent 逃出挂载的数据卷。\n+\n+**除非有意沙箱化写入，否则不要将此变量加入 `~/.hermes/.env`。** 常见错误是将其指向项目目录，却期望 agent 编辑 `~/.hermes/cron/jobs.json`、`~/.hermes/skills/` 或 profile 下的脚本——这些路径在沙箱外，每次 `write_file`/`patch` 都会失败并返回 `outside HERMES_WRITE_SAFE_ROOT` 错误。\n+\n+若需同时允许工作区和 Hermes 状态目录，列出两个前缀（顺序无关）：\n+\n+```bash\n+export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes\n+```\n+\n+取消设置或从 `.env` 中移除此变量可恢复常规写入（仍受凭证路径拒绝列表约束——见[文件写入安全](../user-guide/security.md#file-write-safety)）。\n+\n ## 界面\n \n | 变量 | 描述 |\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}