OpenClaw 2026.6.6, macOS launchd install.
Symptom
A plugin that registers a single before_tool_call hook sees its handler execute twice for a subset (~4%) of tool calls, clustered around isolated-agent / cron invocations. In our case ClawLens logs two decision audit entries ~25–36ms apart and emits its own audit double-write warning. The hook is registered exactly once (api.on("before_tool_call", handler, { priority: 100 })), so this is not double-registration.
Root cause (traced through the dist bundle)
The before_tool_call hook is applied to tools via wrapToolWithBeforeToolCallHook. Most call sites guard against re-wrapping with isToolWrappedWithBeforeToolCallHook:
native-hook-relay-*.js — guarded ✓
openclaw-tools-*.js — guarded ✓
agent-tools-*.js (the ~:1242 site) — guarded ✓
But the createOpenClawCodingTools path does not:
// agent-tools-*.js (createOpenClawCodingTools)
const normalized = subagentFiltered.map((tool) => normalizeToolParameters(tool, {...}));
const withHooks = normalized.map((tool) => wrapToolWithBeforeToolCallHook(tool, {...})); // <-- no isToolWrappedWithBeforeToolCallHook guard
Worse, even adding a guard here would not be sufficient, because normalizeToolParameters constructs a fresh tool via object spread:
return { ...tool, ...addEmptyObjectArgumentPreparation(tool, parameters), parameters };
The wrap marker is a Symbol (BEFORE_TOOL_CALL_WRAPPED), and object spread does not copy Symbol-keyed properties. So normalization strips the marker off an already-wrapped tool, after which the unconditional wrapToolWithBeforeToolCallHook re-wraps it. The result is nested wrappers — each wrappers executecallsrunBeforeToolCallHookbefore delegating to the inner (also-wrapped)execute` — so the hook runs twice per invocation.
This fires for cron / isolated-agent spawns because those rebuild their tool set through createOpenClawCodingTools → normalizeToolParameters → wrapToolWithBeforeToolCallHook, picking up tools that were already wrapped in a prior context.
Impact
before_tool_call plugin hooks run twice → duplicate side effects (audit rows, risk eval, alerts, anything a guard plugin does per decision).
- Deterministic for affected tools; intermittent in aggregate because it depends on which tools carry a prior wrap.
Suggested fix (maintainers to choose placement)
Either:
- Make
normalizeToolParameters preserve the BEFORE_TOOL_CALL_WRAPPED Symbol marker (and any other wrap markers), or
- Guard the
createOpenClawCodingTools wrap site with isToolWrappedWithBeforeToolCallHook — but only works if the marker survives normalization, so (1) is likely required regardless.
A defensive isToolWrappedWithBeforeToolCallHook check inside wrapToolWithBeforeToolCallHook itself would be robust against all call sites, but is also defeated by the marker being stripped during normalization — so the marker-preservation fix (1) is the core one.
Happy to test a fix against this install.
OpenClaw 2026.6.6, macOS launchd install.
Symptom
A plugin that registers a single
before_tool_callhook sees its handler execute twice for a subset (~4%) of tool calls, clustered around isolated-agent / cron invocations. In our case ClawLens logs two decision audit entries ~25–36ms apart and emits its ownaudit double-writewarning. The hook is registered exactly once (api.on("before_tool_call", handler, { priority: 100 })), so this is not double-registration.Root cause (traced through the dist bundle)
The
before_tool_callhook is applied to tools viawrapToolWithBeforeToolCallHook. Most call sites guard against re-wrapping withisToolWrappedWithBeforeToolCallHook:native-hook-relay-*.js— guarded ✓openclaw-tools-*.js— guarded ✓agent-tools-*.js(the~:1242site) — guarded ✓But the
createOpenClawCodingToolspath does not:Worse, even adding a guard here would not be sufficient, because
normalizeToolParametersconstructs a fresh tool via object spread:The wrap marker is a Symbol (
BEFORE_TOOL_CALL_WRAPPED), and object spread does not copy Symbol-keyed properties. So normalization strips the marker off an already-wrapped tool, after which the unconditionalwrapToolWithBeforeToolCallHookre-wraps it. The result is nested wrappers — each wrappersexecutecallsrunBeforeToolCallHookbefore delegating to the inner (also-wrapped)execute` — so the hook runs twice per invocation.This fires for cron / isolated-agent spawns because those rebuild their tool set through
createOpenClawCodingTools→normalizeToolParameters→wrapToolWithBeforeToolCallHook, picking up tools that were already wrapped in a prior context.Impact
before_tool_callplugin hooks run twice → duplicate side effects (audit rows, risk eval, alerts, anything a guard plugin does per decision).Suggested fix (maintainers to choose placement)
Either:
normalizeToolParameterspreserve theBEFORE_TOOL_CALL_WRAPPEDSymbol marker (and any other wrap markers), orcreateOpenClawCodingToolswrap site withisToolWrappedWithBeforeToolCallHook— but only works if the marker survives normalization, so (1) is likely required regardless.A defensive
isToolWrappedWithBeforeToolCallHookcheck insidewrapToolWithBeforeToolCallHookitself would be robust against all call sites, but is also defeated by the marker being stripped during normalization — so the marker-preservation fix (1) is the core one.Happy to test a fix against this install.