hermes - 💡(How to fix) Fix [Bug]: ACP denied edits can be silently reattempted through alternate write-capable tools

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

"error": "Edit approval denied by ACP client; file was not modified." "error": "Edit approval denied by ACP client; file was not modified." The direct denied file tool is blocked, but the denial is returned as an ordinary JSON tool error. The active turn continues, and later calls to terminal or execute_code can still mutate the same file without a new ACP permission request. return json.dumps({"error": "Edit approval denied by ACP client; file was not modified."}, ensure_ascii=False) No later tool call is correlated with the denied target or denied diff, and no fresh permission request is made for an equivalent opaque write. The model therefore sees a recoverable tool error and may try another implementation route.

Root Cause

model_tools.py invokes ACP edit approval before dispatch:

from acp_adapter.edit_approval import maybe_require_edit_approval

edit_block_message = maybe_require_edit_approval(function_name, function_args)
if edit_block_message is not None:
    return edit_block_message

But acp_adapter/edit_approval.py::build_edit_proposal() only builds proposals for direct file tools:

def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None:
    """Return an edit proposal for supported file mutation calls."""

    if tool_name == "write_file":
        return _proposal_for_write_file(arguments)
    if tool_name == "patch" and arguments.get("mode", "replace") == "replace":
        return _proposal_for_patch_replace(arguments)
    return None

For terminal and execute_code, proposal is None, so maybe_require_edit_approval() returns None and dispatch continues.

When the user denies a supported edit, the denial is returned as a normal tool result:

if approved:
    return None
return json.dumps({"error": "Edit approval denied by ACP client; file was not modified."}, ensure_ascii=False)

No later tool call is correlated with the denied target or denied diff, and no fresh permission request is made for an equivalent opaque write. The model therefore sees a recoverable tool error and may try another implementation route.

This is tool-shape-specific approval rather than intent/effect-specific approval.

Fix Action

Fix / Workaround

Hermes ACP edit approval correctly asks the client for permission before direct file-edit tools such as write_file and replace-mode patch. However, after an ACP client rejects an edit request, the same ACP turn can still apply the rejected file mutation through a different write-capable tool surface, such as terminal or execute_code, without any fresh permission prompt.

This is a Hermes server-side approval/dispatch gap. The ACP permission round-trip works: the client denies the presented edit and Hermes blocks that direct tool call. The missing behavior is that a later opaque write attempt to the same target is allowed to run silently instead of being converted into a new explicit permission decision.

A minimal repro does not require a full editor/ACP client. It can be reproduced by binding the ACP edit requester to deny, then dispatching tools directly from a Hermes checkout.

Code Example

python - <<'PY'
import json
import tempfile
from pathlib import Path

from acp_adapter.edit_approval import (
    clear_edit_approval_requester,
    set_edit_approval_requester,
)
from model_tools import handle_function_call

with tempfile.TemporaryDirectory(prefix="hermes-acp-denial-repro-") as d:
    target = Path(d) / "sample.txt"
    target.write_text("before\n", encoding="utf-8")

    clear_edit_approval_requester()
    set_edit_approval_requester(lambda _proposal: False)

    denied = json.loads(
        handle_function_call(
            "write_file",
            {"path": str(target), "content": "after\n"},
            task_id="acp-denial-repro",
        )
    )

    terminal_cmd = (
        "python - <<'INNER'\n"
        "from pathlib import Path\n"
        f"Path({str(target)!r}).write_text('after via terminal\\n', encoding='utf-8')\n"
        "INNER"
    )
    terminal_result = json.loads(
        handle_function_call(
            "terminal",
            {"command": terminal_cmd, "timeout": 30},
            task_id="acp-denial-repro",
        )
    )

    print(json.dumps({
        "write_file_result": denied,
        "terminal_exit_code": terminal_result.get("exit_code"),
        "final_file_content": target.read_text(encoding="utf-8"),
    }, indent=2))

    clear_edit_approval_requester()
PY

---

{
  "write_file_result": {
    "error": "Edit approval denied by ACP client; file was not modified."
  },
  "terminal_exit_code": 0,
  "final_file_content": "after via terminal\n"
}

---

python - <<'PY'
import json
import tempfile
from pathlib import Path

from acp_adapter.edit_approval import (
    clear_edit_approval_requester,
    set_edit_approval_requester,
)
from model_tools import handle_function_call

with tempfile.TemporaryDirectory(prefix="hermes-acp-denial-repro-exec-") as d:
    target = Path(d) / "sample.txt"
    target.write_text("before\n", encoding="utf-8")

    clear_edit_approval_requester()
    set_edit_approval_requester(lambda _proposal: False)

    denied = json.loads(
        handle_function_call(
            "write_file",
            {"path": str(target), "content": "after\n"},
            task_id="acp-denial-repro-exec",
        )
    )

    code = (
        "from pathlib import Path\n"
        f"Path({str(target)!r}).write_text('after via execute_code\\n', encoding='utf-8')\n"
        "print('done')\n"
    )
    exec_result = json.loads(
        handle_function_call(
            "execute_code",
            {"code": code},
            task_id="acp-denial-repro-exec",
            enabled_tools=["execute_code"],
        )
    )

    print(json.dumps({
        "write_file_result": denied,
        "execute_code_status": exec_result.get("status"),
        "final_file_content": target.read_text(encoding="utf-8"),
    }, indent=2))

    clear_edit_approval_requester()
PY

---

{
  "write_file_result": {
    "error": "Edit approval denied by ACP client; file was not modified."
  },
  "execute_code_status": "success",
  "final_file_content": "after via execute_code\n"
}

---

186bf25cb11077b8c158dbfc1f768e48bc28b0db

---

Python: 3.11.15

---

Python 3.14.5

---

Hermes Agent v0.14.0 (2026.5.16)

---

from acp_adapter.edit_approval import maybe_require_edit_approval

edit_block_message = maybe_require_edit_approval(function_name, function_args)
if edit_block_message is not None:
    return edit_block_message

---

def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None:
    """Return an edit proposal for supported file mutation calls."""

    if tool_name == "write_file":
        return _proposal_for_write_file(arguments)
    if tool_name == "patch" and arguments.get("mode", "replace") == "replace":
        return _proposal_for_patch_replace(arguments)
    return None

---

if approved:
    return None
return json.dumps({"error": "Edit approval denied by ACP client; file was not modified."}, ensure_ascii=False)
RAW_BUFFERClick to expand / collapse

Bug Description

Hermes ACP edit approval correctly asks the client for permission before direct file-edit tools such as write_file and replace-mode patch. However, after an ACP client rejects an edit request, the same ACP turn can still apply the rejected file mutation through a different write-capable tool surface, such as terminal or execute_code, without any fresh permission prompt.

This is a Hermes server-side approval/dispatch gap. The ACP permission round-trip works: the client denies the presented edit and Hermes blocks that direct tool call. The missing behavior is that a later opaque write attempt to the same target is allowed to run silently instead of being converted into a new explicit permission decision.

This should not require treating the prior denial as a permanent ban on the file. ACP reject_once naturally means the presented operation was rejected. The bug is that Hermes can immediately reattempt the same file mutation through a less transparent surface without asking again.

Steps to Reproduce

A minimal repro does not require a full editor/ACP client. It can be reproduced by binding the ACP edit requester to deny, then dispatching tools directly from a Hermes checkout.

From the Hermes repo root:

python - <<'PY'
import json
import tempfile
from pathlib import Path

from acp_adapter.edit_approval import (
    clear_edit_approval_requester,
    set_edit_approval_requester,
)
from model_tools import handle_function_call

with tempfile.TemporaryDirectory(prefix="hermes-acp-denial-repro-") as d:
    target = Path(d) / "sample.txt"
    target.write_text("before\n", encoding="utf-8")

    clear_edit_approval_requester()
    set_edit_approval_requester(lambda _proposal: False)

    denied = json.loads(
        handle_function_call(
            "write_file",
            {"path": str(target), "content": "after\n"},
            task_id="acp-denial-repro",
        )
    )

    terminal_cmd = (
        "python - <<'INNER'\n"
        "from pathlib import Path\n"
        f"Path({str(target)!r}).write_text('after via terminal\\n', encoding='utf-8')\n"
        "INNER"
    )
    terminal_result = json.loads(
        handle_function_call(
            "terminal",
            {"command": terminal_cmd, "timeout": 30},
            task_id="acp-denial-repro",
        )
    )

    print(json.dumps({
        "write_file_result": denied,
        "terminal_exit_code": terminal_result.get("exit_code"),
        "final_file_content": target.read_text(encoding="utf-8"),
    }, indent=2))

    clear_edit_approval_requester()
PY

Observed output shape:

{
  "write_file_result": {
    "error": "Edit approval denied by ACP client; file was not modified."
  },
  "terminal_exit_code": 0,
  "final_file_content": "after via terminal\n"
}

The same silent reattempt is possible with execute_code:

python - <<'PY'
import json
import tempfile
from pathlib import Path

from acp_adapter.edit_approval import (
    clear_edit_approval_requester,
    set_edit_approval_requester,
)
from model_tools import handle_function_call

with tempfile.TemporaryDirectory(prefix="hermes-acp-denial-repro-exec-") as d:
    target = Path(d) / "sample.txt"
    target.write_text("before\n", encoding="utf-8")

    clear_edit_approval_requester()
    set_edit_approval_requester(lambda _proposal: False)

    denied = json.loads(
        handle_function_call(
            "write_file",
            {"path": str(target), "content": "after\n"},
            task_id="acp-denial-repro-exec",
        )
    )

    code = (
        "from pathlib import Path\n"
        f"Path({str(target)!r}).write_text('after via execute_code\\n', encoding='utf-8')\n"
        "print('done')\n"
    )
    exec_result = json.loads(
        handle_function_call(
            "execute_code",
            {"code": code},
            task_id="acp-denial-repro-exec",
            enabled_tools=["execute_code"],
        )
    )

    print(json.dumps({
        "write_file_result": denied,
        "execute_code_status": exec_result.get("status"),
        "final_file_content": target.read_text(encoding="utf-8"),
    }, indent=2))

    clear_edit_approval_requester()
PY

Observed output shape:

{
  "write_file_result": {
    "error": "Edit approval denied by ACP client; file was not modified."
  },
  "execute_code_status": "success",
  "final_file_content": "after via execute_code\n"
}

I also observed this end-to-end in an ACP editor session: the user denied direct write_file / patch prompts, Hermes reported Edit approval denied by ACP client; file was not modified., and the following assistant tool call used a Python script through terminal to write the same target file successfully with exit_code=0.

Expected Behavior

After an ACP edit approval is denied, Hermes should not silently perform the same file mutation through another write-capable surface.

Concretely:

  • A denied write_file / patch proposal may be followed by a corrected or alternative operation, but only if that later operation gets a fresh explicit permission decision when it may mutate the same target.
  • As a first fix, obvious same-path top-level terminal / execute_code writes should request permission before mutating a previously denied target in the same turn/session context.
  • Broader opaque-write coverage, such as nested execute_code Hermes tools, terminal workdir + relative paths, direct V4A patch mode="patch", or future script-execution tools, should be handled as follow-up unless Hermes can accurately present the operation for approval.
  • If Hermes cannot make a later operation intelligible enough to ask safely, it should refuse or require explicit approval rather than proceed silently.
  • Read-only terminal/code usage after a denial should remain possible when it does not target/rewrite the denied file.
  • A prior denial should not permanently poison the path; a later corrected edit should be approvable through a normal/fresh prompt.

Actual Behavior

The direct denied file tool is blocked, but the denial is returned as an ordinary JSON tool error. The active turn continues, and later calls to terminal or execute_code can still mutate the same file without a new ACP permission request.

Affected Component

  • ACP server / adapter
  • Tools: file operations, terminal, code execution
  • Agent core tool dispatch / approval semantics

Messaging Platform (if gateway-related)

N/A. This is ACP/editor integration behavior, not a gateway platform issue.

Debug Report

No public debug bundle is attached because the minimal reproduction above uses a temp directory and does not depend on private logs or editor state.

The code path was also checked against origin/main at:

186bf25cb11077b8c158dbfc1f768e48bc28b0db

Operating System

Observed on Linux / Arch, but the bug is in Hermes tool-dispatch policy and should not be OS-specific.

Python Version

Hermes runtime reported:

Python: 3.11.15

The shell python used for local probes was:

Python 3.14.5

Hermes Version

Installed Hermes reported:

Hermes Agent v0.14.0 (2026.5.16)

The relevant code path was confirmed on current origin/main as noted above.

Root Cause Analysis

model_tools.py invokes ACP edit approval before dispatch:

from acp_adapter.edit_approval import maybe_require_edit_approval

edit_block_message = maybe_require_edit_approval(function_name, function_args)
if edit_block_message is not None:
    return edit_block_message

But acp_adapter/edit_approval.py::build_edit_proposal() only builds proposals for direct file tools:

def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None:
    """Return an edit proposal for supported file mutation calls."""

    if tool_name == "write_file":
        return _proposal_for_write_file(arguments)
    if tool_name == "patch" and arguments.get("mode", "replace") == "replace":
        return _proposal_for_patch_replace(arguments)
    return None

For terminal and execute_code, proposal is None, so maybe_require_edit_approval() returns None and dispatch continues.

When the user denies a supported edit, the denial is returned as a normal tool result:

if approved:
    return None
return json.dumps({"error": "Edit approval denied by ACP client; file was not modified."}, ensure_ascii=False)

No later tool call is correlated with the denied target or denied diff, and no fresh permission request is made for an equivalent opaque write. The model therefore sees a recoverable tool error and may try another implementation route.

This is tool-shape-specific approval rather than intent/effect-specific approval.

ACP Protocol Notes

ACP exposes session/request_permission for sensitive operations, with permission options such as allow_once, allow_always, reject_once, and reject_always. A literal protocol reading of reject_once rejects the presented tool operation, so this may not be an ACP spec violation by itself.

That nuance is important: a denied edit should not necessarily become a permanent server-side ban on the path. A user may reject one proposed diff, then approve a corrected diff to the same file.

However, from the user's perspective, the permission prompt is for a file mutation shown as an edit/diff. If the user rejects that edit, Hermes should not silently achieve the same mutation through a different tool surface in the same turn. The later execute-style operation should either ask again with clear context, route through edit approval when possible, or be refused if Hermes cannot safely present/approve it.

This should be enforced in hermes-agent, not in ACP clients or ACP proxies/muxes. The client already returned the denial correctly; only the server knows what later tools it is about to execute.

Related Issues / PRs Checked

I did not find an exact open duplicate for this issue using searches for:

  • "Edit approval denied by ACP client"
  • "terminal file writes" ACP
  • "write_text" "Edit approval"
  • "denied" "terminal" "edit approval"
  • "write_file" "execute_code" "approval"

Adjacent but not exact:

Proposed Fix

Prefer a fresh-permission design over sticky deny propagation.

When an ACP edit proposal is denied:

  1. Remember enough turn/session context to recognize later attempts that may mutate the same target:
    • normalized path or paths
    • original tool name
    • old/new content hashes or diff hash when available
    • turn/session marker
  2. Before dispatching later write-capable/opaque tools, check whether the operation appears to target a recently denied path or reproduce the denied mutation.
  3. If it does, request a fresh ACP permission decision before execution. The prompt should make clear that this is an alternate write surface after a rejected edit.
  4. If Hermes cannot present the later operation clearly enough to approve safely, refuse it instead of executing silently.
  5. If the user approves the fresh request, allow the corrected/alternate operation and clear or update the relevant denied-attempt state as appropriate.

Initial narrow fix should guard:

  • top-level terminal command text that explicitly mentions the denied raw/resolved path, including simple shell redirection targets after quote expansion;
  • top-level execute_code snippets that explicitly mention the denied raw/resolved path; and
  • obvious write intent such as write_text( / write_bytes(, write-mode open(...), shell redirects > / >>, tee, cp / mv, touch, truncate, rm, or in-place sed/perl patterns.

Follow-up robust coverage should handle:

  • nested execute_code Hermes tool calls such as hermes_tools.write_file(...);
  • terminal workdir + relative path writes;
  • direct file tools not yet covered by build_edit_proposal (patch mode=patch, unless/until PR #28120 or equivalent lands); and
  • future shell/script execution tools.

For direct file tools, prefer normal edit proposals when possible. For opaque tools, the initial implementation can be conservative: detect strong same-target write intent and ask for permission before running. Examples in the narrow first pass include:

  • command/script text referencing the denied raw or resolved path
  • simple shell redirect targets after quote expansion
  • write_text( / write_bytes(
  • open(..., "w"|"a"|"x")
  • shell redirects > / >>
  • tee
  • heredoc-to-file patterns
  • cp / mv into the denied path

Follow-up coverage should handle relative paths under workdir and nested Hermes tool calls that write to the denied path.

If classification is uncertain immediately after an edit denial, prefer asking again or refusing over proceeding silently.

A simpler but more aggressive fallback would be to end/cancel the active turn after an edit denial. That is safer, but may be less ergonomic if the user expects the agent to continue with read-only explanation, tests, or a corrected edit proposal.

Proposed Regression Tests

Denied write_file causes equivalent terminal write to request fresh permission

Arrange:

  • Bind an ACP edit approval requester that denies the first write_file proposal.
  • Call handle_function_call("write_file", {"path": target, "content": "..."}).
  • Assert the result contains Edit approval denied by ACP client.
  • Then call handle_function_call("terminal", {"command": "python - <<'PY'\nfrom pathlib import Path\nPath('<target>').write_text('...')\nPY"}) in the same ACP context.

Assert:

  • The terminal command is not executed silently.
  • Hermes asks for a fresh permission decision or returns a refusal if no safe permission requester is available.
  • If the fresh decision is denied, the file remains unchanged.

Fresh approval allows a corrected alternate write

Arrange:

  • Deny an initial write_file proposal for a target.
  • Follow with an alternate terminal / execute_code operation that writes different corrected content to the same target.
  • Bind the relevant requester to approve the fresh alternate-surface permission request.

Assert:

  • The second operation does not bypass approval; a fresh request is observed.
  • After approval, the corrected write may proceed.
  • The prior denial does not permanently poison the path.

Denied write_file causes equivalent execute_code write to request fresh permission

Same as above, but the second tool is execute_code writing the target path via Python.

Follow-up: nested execute_code Hermes tool writes

Arrange:

  • Deny a direct edit to a target path.
  • Then run execute_code containing from hermes_tools import write_file; write_file("<target>", "...").

Assert:

  • The nested tool call routes through the same approval/reattempt guard.
  • It cannot silently mutate the denied target.

Follow-up: terminal workdir + relative path writes

Arrange:

  • Deny a direct edit to /tmp/dir/sample.txt.
  • Then run terminal with workdir=/tmp/dir and a command such as printf ... > sample.txt.

Assert:

  • The relative write is recognized as targeting the denied file.
  • It asks again or refuses instead of silently modifying the file.

Denied patch causes equivalent opaque rewrite to request fresh permission

Arrange:

  • Existing file contains old text.
  • ACP requester denies a patch proposal for that file.
  • Follow with terminal or execute_code that rewrites the same file.

Assert:

  • The later write-capable tool asks again or refuses.
  • The file remains unchanged if the fresh request is denied.

Harmless read-only terminal/code remains allowed after edit denial

After a denied edit, read-only commands such as pwd, git diff --stat, python -m pytest --collect-only, or a script that reads the denied file should remain allowed when they do not target/rewrite the denied file.

Denial/reattempt state resets at the right boundary

A denial should not poison all future sessions forever. Verify any reattempt guard is scoped to the active ACP turn/session context and can be cleared by a later explicit permission decision.

Non-ACP sessions unaffected

Because ACP edit approval is bound through a ContextVar, CLI/gateway sessions should keep existing behavior unless they explicitly opt into the same policy.

Acceptance Criteria

  • Denying an ACP edit approval prevents the denied file mutation from being applied through direct edit tools or obvious same-path top-level terminal / execute_code write attempts without a new explicit permission decision.
  • Later equivalent write attempts trigger a fresh ACP permission request or safe refusal rather than silently succeeding.
  • Simple shell-quoted terminal redirect targets that resolve to the denied path are detected, so paths containing quotes cannot bypass the literal path-string check.
  • A later corrected edit to the same path can proceed if the user explicitly approves the fresh request.
  • Direct denied tools still report that no file was modified.
  • Read-only terminal/code commands remain usable after a denial.
  • Existing dangerous-command approval behavior is not weakened.
  • Non-ACP sessions are not accidentally forced through ACP edit approval.
  • Tests cover denied direct edit followed by top-level terminal and execute_code reattempts, denial/failure of the fresh reattempt approval, shell-quoted terminal redirect targets, and read-only terminal access.
  • Nested execute_code Hermes-tool writes, terminal workdir + relative writes, and V4A patch mode="patch" are explicitly documented as follow-up scope unless included in the same implementation.

Non-goals

  • Do not put this burden on ACP clients. The client already denied the permission request; server-side Hermes must enforce the semantics.
  • Do not require ACP proxies/muxes to infer hidden write intent from opaque tool calls.
  • Do not globally ban all terminal usage after any edit denial unless that is an intentional product decision.
  • Do not permanently ban writes to a path after one rejected edit; a later corrected operation should be approvable.
  • Do not solve arbitrary shell/code sandboxing in the first pass; the narrow fix covers static obvious same-path top-level terminal / execute_code reattempts and leaves nested tool RPC, relative-workdir writes, and unrelated direct-file gaps as explicit follow-up scope.
  • Do not rely only on prompt wording. The fix should be enforced in tool dispatch / policy code.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - 💡(How to fix) Fix [Bug]: ACP denied edits can be silently reattempted through alternate write-capable tools