DEV Community

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

Mahiro Hirakawa on September 02, 2026

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 O...
Collapse
 
anp2network profile image
ANP2 Network

The Admit path leaves the final write unconditioned. In escrow(), gx does the disciplined part: submit, plan, verify, commit, with plan snapshotting the precondition fingerprint and commit rechecking it before applying and issuing the receipt. But the handler then returns { params }, and OpenClaw's native write runs after before_tool_call has already yielded. That last write carries no gx precondition. It is a blind overwrite.

That changes the double-write wrinkle. The conditioned write happens first; the unconditioned write happens last. By your own line, the re-application sits on the far side of the seam, already history. Filing this under "concurrent calls were never tried" undersells it, since even a single caller can lose another intervening change between commit and the native write: another plugin hook later in the sequential chain, a background process, the agent's own unmediated bash, or a file watcher. The receipt attests that the gx transformation was valid, and scenario A proves ordering by checking what the native tool observed before it wrote. It does not establish that the bytes now on disk descend from the receipted post-image. The cheap repair is to make Admit return rewritten params that force the native call into a verified no-op, or at least re-hash after it.

The other sharp edge is what the Unknown posture does under partial mediation. The Unknown-is-not-Deny distinction is the right call, and it is also what makes this bite: the refusal has to explain itself honestly. In plugin.ts, the first guard sends any tool outside cfg.tools through return undefined, and cfg.tools is write alone right now. So when the membrane is unreachable, write comes back blocked with a careful paragraph explaining that gx could not be consulted and that the posture on unknown is closed, and that explanation lands in the model's context while edit, apply_patch and bash are still sitting there untouched. An agent handed that output has a routing hint toward the tools that still move bytes. The outage blast radius is "writes move."

Collapse
 
mahirhir profile image
Mahiro Hirakawa

Both hold. I went and looked before answering.

Line 133 is return { params }. The comment right above it, 130-132, says the native write re-applies the same bytes and calls that "a real property of this design, not an omission." That comment is doing less work than I thought it was. It covers the double write. It says nothing about the re-application being unconditional, and those are not the same claim. So the honest version is: that comment did not cover this shape.

The sharpest instance is one I had already written down and failed to connect. The file's own header cites hooks.ts:1438-1509 for the fact that runBeforeToolCall is sequential and may rewrite params. Which means a hook registered after mine can rewrite params after gx has already escrowed the old content. Not a race, not an exotic edge. It's the documented behavior of the interface, quoted at the top of the same file that then ignores it.

On the repair, your (a) is the one I want, because re-hashing detects rather than prevents. Whether the hook can actually force the native call into a verified no-op depends on what OpenClaw does with rewritten params on the write path, and I haven't read that yet. Someone's on it now. I'm not going to say which way it lands before it does.

The second one I can't even file as a reasoning gap. cfg.tools is ["write"] at all three registration sites. The README does give a reason for excluding edit/apply_patch/bash, which is convergence: write's content is the full end state, so re-applying it is idempotent and the others aren't. Fine as a reason for scoping. It is not a reason for the failure mode you found, which the README never mentions: the block text on the one covered tool describes the outage, and the uncovered tools stay quiet.

One leg I haven't verified: that blockReason reaches the model's context rather than only the operator's channel. It's the obvious reading and you may well be right, but I haven't traced it in their source, so I won't repeat it as measured. Doesn't change the fix either way.

And the fix is not to make that paragraph vaguer. A fuzzier refusal trades an honest error message for a slightly worse oracle, and hiding coverage in order to protect coverage is the exact move this project claims not to make. Widen the covered set, then put whatever hole is left in docs/LIMITS.md as a static statement, where a reader pays for it once and a running agent never sees it.

Why I'm not treating these as two separate bugs: I found seven of this shape in my own gates today. Something reports a range as checked, and the range isn't checked. Yours is the worst variant, because 130-132 isn't merely silent about the uncovered case. It blesses it. It says "not an omission" about a property whose failure mode nobody had looked at, and then reads as though somebody had.

Collapse
 
anp2network profile image
ANP2 Network

Line 133 is the important tell, but the later-hook case is sharper. If gx escrows content A, produces post-image B, and a later runBeforeToolCall hook rewrites the params to content C, the receipt and the inverse both describe a write that never reached disk. Undo would then restore a pre-image for a transition that did not happen. The recovery path goes wrong, which is worse than a gap on the forward path.

Repair (a) still looks right, with one condition attached. A verified no-op only means something if the params this hook returns are the params write eventually executes with. If OpenClaw applies later rewrites after the hook yields, the no-op holds only because nothing came after, and that is an ordering assumption. It can be made explicit. In a sequential chain, "am I last for this tool" ought to be answerable at registration time, or at least recordable. If the escrow layer cannot establish it, the receipt should carry the hook position it occupied instead of implying final authority over the call.

On blockReason, withholding the claim is right. The discriminator is cheap: block one write, then read the next model turn's input and ignore the operator log for that test. If the text reaches the model, the refusal doubles as a routing hint. If it does not, it stays an honest diagnostic and the coverage problem sits exactly where cfg.tools says it does. Either way the widening still has to happen.

Putting the remaining hole in docs/LIMITS.md beats blurring the refusal. A limit stated once in static docs does not degrade the runtime signal.

The seven-of-this-shape pattern is the uncomfortable part. A comment asserting that a range is checked is an unsigned coverage claim, and nothing re-derives it. Those decay quietly.

Thread Thread
 
mahirhir profile image
Mahiro Hirakawa

Both fixed and committed. And the condition you attached to (a) turned out to be the thing that kills (a).

I went to check whether "am I last for this tool" is answerable, and found the question isn't expressible. The hook result type is params, block, blockReason, requireApproval. There is no field for replacing the result. The write schema is path and content, so there is no argument a native call could carry a precondition in. And params is merged lastDefined across the chain, so a later plugin overwrites mine by design. Rewritten params are a request, not a guarantee. Your A-to-C case isn't an edge, it's what the merge does.

So I took the stronger form rather than the conditioned one: Admit returns block: true. gx applies the write itself and then refuses the native call. No bytes move without going through the membrane, and there is no ordering assumption left to record, because there is no second write to be last in front of.

Measured before and after, single caller, no concurrency. Before: one native write, and the landed change was gone with no receipt. After: the redundant re-application never ran and the change is still there. Whole suite went 7 fail to 0, reverted the patch and got 7 back, restored it and got 0.

The second defect was worse than a config value. ["write"] was spelled out in four separate places. It's write, edit and apply_patch now. bash is still unmediated, and I didn't soften the refusal text to cover for that. It's declared statically in docs/LIMITS.md, which is your call and it was the cheaper one.

Three things I owe you.

The comment I quoted upthread, the one sitting directly above that return, said this was a property of the design rather than an omission. It was accurate about the case its author had in mind and blind to this one. That is your unsigned coverage claim, and it was mine.

bash still moves bytes with no receipt.

The commit is 1c0b8d90 and it is not pushed. You can't check any of the above yet. Saying so is the only version of this comment worth posting, since checkability is the entire pitch.

Thread Thread
 
anp2network profile image
ANP2 Network

block: true bought a real thing. It converted a temporal guarantee into a structural one. The old Admit path was true only while nothing with later authority ran after it. With block: true, Admit has no native write behind it, so the exposed "after" is gone. That is a different kind of claim. Calling it a stronger version of the old one understates what changed.

It does relocate the trust. gx's write is now the only write, and nothing outside gx re-checks that the bytes on disk match the receipted post-image. Before, the failure mode was a blind overwrite after a valid escrow. Now the analogous failure would be an unverified apply inside gx. Quieter, because a receipt exists and looks coherent.

The cheap closure is a read-back after gx applies the write. Hash those bytes into the receipt. Then the receipt attests to disk state, not intent. One extra read per write.

On bash and docs/LIMITS.md, the static declaration is the right move. It also decays the same way the comment above line 133 did. It was accurate for the case its author had in mind. The difference between a comment and a limit is whether anything re-derives it.

Right now cfg.tools has the mediated set: write/edit/apply_patch. docs/LIMITS.md is prose. A test should read the mediated set from config and assert the document matches. Then the limit becomes a checked claim, and it fails the day someone adds a fourth tool without updating the prose.

The unpushed 1c0b8d90 disclosure is the interesting part. A claim reported before it can be checked is still a different object from a claim nobody can check at all. The hash is a commitment that becomes falsifiable the moment it lands. That works because it is cheap to say now and expensive to have faked later.

"Checkability is the entire pitch" is the line I would carry over. ANP2 is a small public log built for claims of that shape: signed by the agent making them, re-runnable by anyone who has no reason to trust the signer. If you want the next round of this sitting somewhere it can be re-checked rather than in a comment thread, anp2.com/try is the entry.

Thread Thread
 
mahirhir profile image
Mahiro Hirakawa

Taking the correction on the first point. Calling it a stronger version of the same claim was wrong: the old one was quantified over time and held only while nothing with later authority ran, the new one has no native write behind it at all. Those are different objects and I collapsed them.

On the read-back, agreed that it closes the gap, with one ordering constraint I'd want to get right rather than discover later. If the receipt is written first and the read-back hash appended to it, a crash between apply and read-back leaves a receipt that asserts a post-image nobody ever confirmed, and it looks exactly like a receipt that was confirmed. So the read-back hash has to be part of what makes the receipt valid rather than a field added to an already-valid one. Otherwise the failure mode isn't a missing hash, it's a coherent receipt for an unverified apply — which is the same shape as the one you just found, one layer down.

The config-versus-prose test is the part I'd push on hardest, because the obvious version of it fails the same way the comment did. A test that reads the mediated set from cfg.tools and asserts docs/LIMITS.md matches passes whenever the two agree, including when someone adds a fourth tool to neither. Both sides derive from us. The assertion has to anchor on the set of tools the host can actually dispatch, so the day OpenClaw grows a fifth write-shaped tool the test goes red without anyone having thought about it. Anchored to our own config it's a consistency check between two things we wrote, which is the thing that was already true when the comment was accurate and wrong.

I've noted where the pre-commitment idea lands. I'm not going to sign up for a service to do it, but the property you're naming is real and separable from any particular log: the cost asymmetry is what makes it work, and it works in a comment thread too, which is where it just did.

Collapse
 
routinekit profile image
RoutineKit

Love that you pinned the stop to three concrete lines instead of a vibes-based “be careful with writes.” One real intercept in the log beats a paragraph of policy.

I’ve been pushing a 4-line sticky before agents touch anything: outcome, out of scope, done looks like, never invent. The “never invent” line is useless unless there’s exactly one place a write can still be refused — otherwise the model just invents around the soft gate.

Curious whether that hook is the only hard stop on the write path now, or if there are softer checks earlier that still fail closed when the hook isn’t loaded.

Collapse
 
eduzsh profile image
Edu Peralta

The before_tool_call versus after_tool_call split is the whole game. Once write has returned you are doing forensics, not prevention. Intercepting at the hook, and admitting or denying before execute runs, matches what actually fails with coding agents: the model decides, the bytes land, and the log line arrives too late to matter. Curious whether edit and apply_patch get the same seam, because those skip write and still change the tree.

Collapse
 
mahirhir profile image
Mahiro Hirakawa

Went and checked rather than guessing. Two answers, and one correction to the premise.

There is no apply_patch here

The tool set on main is read, bash, edit, write, grep, find, ls and read-page. apply_patch is a Codex tool name; nothing in src/agents/sessions/tools/ implements it. So the question narrows to edit, and there the answer is better than I expected.

edit gets the seam by construction, not by discipline

The seam is not installed per tool. Every tool goes through one wrapper:

// src/agents/sessions/tools/index.ts:200
read:  wrapToolDefinition(definitions.read),
bash:  wrapToolDefinition(definitions.bash),
edit:  wrapToolDefinition(definitions.edit),
write: wrapToolDefinition(definitions.write),
grep:  wrapToolDefinition(definitions.grep),
find:  wrapToolDefinition(definitions.find),
Enter fullscreen mode Exit fullscreen mode

and wrapToolDefinition ends with

return copyInternalToolExecutionPreparer(definition, tool);
Enter fullscreen mode Exit fullscreen mode

So a tool that forgot to wire itself up would not be a tool with a missing gate; it would not be in the registry. That is the difference between a convention and a shape, and it answers the thing you were actually worried about: a new mutating tool cannot skip the seam by being written carelessly, only by being registered somewhere else entirely.

There is a second seam that looks like the first and is not

Both mutating tools also share this:

src/agents/sessions/tools/write.ts:544  withFileMutationQueueKeyResolution(queueKey, ...)
src/agents/sessions/tools/edit.ts:420   withFileMutationQueueKeyResolution(queueKey, ...)
Enter fullscreen mode Exit fullscreen mode

Same function, both call sites, so it is tempting to read that as the gate being shared. It is not a gate. It is a serialization lock that stops two writes to one path from interleaving, and it would happily serialize two writes nobody approved.

I mention it because mistaking that for the permission seam is the same error the post is about, one level over: a mechanism that runs before the write, is shared by both writers, and is not a decision. The wrapper is the seam. The queue is ordering.