DEV Community

Mahiro Hirakawa
Mahiro Hirakawa

Posted on

The one place in OpenClaw's tool path where a write can still be stopped

The one place in OpenClaw's tool path where a write can still be stopped

Falsifier, up front: everything below rests on three lines of OpenClaw's own source, quoted with
file and line number so you can go check them yourself, plus a real run of the plugin inside a real
OpenClaw gateway. Update (2026-09-02): that end-to-end run happened. A real openclaw process (npm
openclaw@2026.8.1, gateway mode) loaded the plugin, and a real DeepSeek-driven agent turn
(--model deepseek/deepseek-chat, answered by deepseek-v4-flash) issued a write tool call that
the hook actually intercepted -- confirmed by a timestamped [gx-escrow] hook fires on write -> ...
line in the gateway log. Three verdicts were exercised, and each was checked against the signed gx
receipt independently, not just the log line: Admit on a normal file write, Deny on an attempt
to overwrite /etc/hostname (denied by policy fs-deny-etc, hash unchanged before/after), and
Escalate on a 1.6MB file that exceeds the inverse-construction limit (hash unchanged, an
approval ticket returned but never resolved by a human). gx undo then restored the Admit write
byte-for-byte, and the receipt verified valid: true. Full logs, signed receipts, and a sha256
manifest are checked into the repo; a recorded terminal session (asciinema GIF) is linked below.
What's still missing, plainly: nobody has resolved an Escalate ticket through OpenClaw's own
approval flow, no genuine third-party (ClawHub-distributed) plugin has been observed using this
hook, the HTTP membrane variant is unbuilt, edit/apply_patch/bash aren't wired, and every
branch above ran exactly once -- this shows the seam works, not that it always will.

An agent using OpenClaw can decide to overwrite a file, and by the time anyone finds out, the bytes
are already on disk. I wanted one point in that path where a write could still be refused before it
lands, not a log entry written after the fact. Turns out OpenClaw has exactly one point like that,
and it isn't obvious until you go read the wrapper instead of guessing from the plugin docs.

Three facts, each read straight out of the source at the commit this demo was built against:

  • src/agents/agent-tools.before-tool-call.wrapper.ts:445-549: the hook's result is evaluated first, and when it blocks, the real execute() is never called. The hook conditions the effect, it doesn't just observe it.
  • src/plugins/hooks.ts:1438-1509: runBeforeToolCall runs sequentially, can block, and can rewrite the call's params before the tool sees them.
  • Right next to it, runAfterToolCall is documented "fire-and-forget". By the time that one runs, there's nothing left to hold onto.

So before_tool_call is the seam. Everything on one side of it can still say no; everything on the
other side is already history. A plugin (src/plugin.ts) sits at that seam and puts a proposed
filesystem write through gx (an escrow-and-inverse layer I've been building) before OpenClaw's
own write tool is allowed anywhere near the disk:

flowchart LR
    A["agent asks to write a file"] --> B{"before_tool_call fires"}
    B -->|"tool != write"| P["hook returns undefined, call passes through untouched"]
    B -->|"write"| C["gx-cli-membrane: submit -> plan -> verify"]
    C -->|"Admit"| D["gx commit: escrow inverse, apply, sign receipt"]
    D --> E["hook returns params unchanged"]
    E --> F["OpenClaw's own write tool runs"]
    F --> G["bytes already match -- this is a re-application, not the first write"]
    C -->|"Deny"| H["hook returns block: true, names the policy"]
    H -.-> X["write tool body never runs"]
    C -->|"Escalate"| I["hook returns requireApproval, OpenClaw owns the human conversation"]
    C -->|"Unknown (membrane unreachable)"| J["hook returns block: true, reason says Unknown -- never Deny"]

The Admit branch is the one with a genuine wrinkle in it. gx has no "escrow without applying"
verb: commit escrows the inverse, re-checks the precondition, and applies, atomically, in one
step. So by the time the hook returns and lets OpenClaw's own write proceed, the bytes are already
sitting on disk. OpenClaw's tool then writes the same content again, a second time, on top of what's
already there. That's a real property of this design, not a bug I'm glossing over. It's stated in
the plugin's own comments, and it's why scenario A of the demo doesn't stop at "the file has the
right content" (true either way) but goes one step further and checks what the native tool observed
right before it wrote. If gx got there first, the native tool finds the change already done.

The other three branches are shorter to explain and, honestly, easier to trust. Deny names the
policy that refused. In the demo it's fs-deny-etc, a policy that ships in the repo, not one written
for the occasion, tested against a real attempt to overwrite /etc/hostname. Escalate hands the
approval back to OpenClaw's own conversation rather than inventing a second one. And Unknown, where
the membrane couldn't be reached at all, gets its own block message that explicitly says it is not
a policy denial, because folding "couldn't ask" into "asked and no" is the one shortcut a
reversibility layer can't take without lying about the one thing it exists to be honest about.

What this doesn't show, plainly: the repo's standalone demo harness (examples/openclaw-plugin-demo/
src/demo.ts
) reproduces the firing order read out of OpenClaw's wrapper rather than running inside
OpenClaw itself -- that part is a reconstruction, and it's separate from the real gateway run
described in the falsifier above. Only fs/write is wired, in both places; git, mcp, and
postgres adapters exist but aren't touched here. In neither the standalone demo nor the real
gateway run did an Escalate ever reach a human: the plugin returns requireApproval, and the
ticket just sits there -- nobody has built or run the approval side of that conversation. Concurrent
calls against the same file were never tried, and neither was running this hook alongside someone
else's plugin.

One thing I got wrong on the first pass, worth admitting because it's a small, honest kind of bug: I
guessed the shape register() needed for its tool matcher ({ tools: [...] }) and shipped that
guess. A real openclaw plugins install run failed it outright: TypeError: tool hook matcher must
be an array of tool names
. The handler's own tool-name check inside the hook body held the scope
correctly the entire time regardless, which is exactly why the wrong shape never mis-scoped anything.
It just never installed at all. Fixed to the bare array once a real install run said so, not before.

Repo: github.com/TraceFold/tracefold, Apache-2.0, Rust
engine with this plugin as a thin TypeScript consumer of its CLI. 14 stars, 4 forks, not a package
release yet: a v0.1.0-alpha tag with one Linux x86_64 tarball built outside CI. I'm not claiming
more than that.

Top comments (0)