Summary
Plugin hooks before_tool_call and after_tool_call are dispatched fire-and-forget (void runHook(...).catch()). A handler returning { cancel: true } does not prevent tool execution — the return value is discarded before evaluation.
This breaks the API contract defined in BeforeToolCallHookResult and prevents security extensions from enforcing tool execution policy. The only awaited hook, message_sending, correctly honors its return values.
fixing this (awaiting the hook) has zero cost when no plugin registers a handler. The hook runner already checks for registered handlers — awaiting an empty list resolves one microtick (~nanoseconds). Latency only applies when a user has explicitly installed a security plugin.
Steps to reproduce
Small test plugin already created test-hook-plugin.zip
-
Create a plugin that registers a before_tool_call hook:
api.on("before_tool_call", async (event) => {
console.log(Hook fired for: ${event.toolName});
return { cancel: true };
});
-
Install the plugin and trigger any tool call (e.g., web_search or exec)
-
Observe: hook fires (log appears), but the tool executes anyway
-
Compare with message_sending — returning { cancel: true } there
actually cancels the message because it's the only awaited hook
Expected behavior
before_tool_call returning { cancel: true } should prevent the tool from executing, matching the semantics defined in the BeforeToolCallHookResult type. Similarly, after_tool_call mutating event.result should change what the LLM sees, since the type declares result as mutable.
This matches how message_sending already works — it's awaited, and its return values (cancel, content modification) are honored.
Actual behavior
Both hooks are dispatched as fire-and-forget. The tool execution path does not await the hook result, so:
before_tool_call: { cancel: true } is returned but never read — tool runs regardless
after_tool_call: result mutations happen after the LLM has already received the raw output
The type system promises enforcement semantics that the runtime does not deliver. 6+ independent security projects (PRs #6095, #14222, #8448, and external projects ClawBands, openclaw-harness, openclaw-shield) have independently hit this gap trying to build tool execution policy enforcement.
OpenClaw version
2026.2.15
Operating system
Ubuntu 24.04
Install method
npm global
Logs, screenshots, and evidence
14:15:05 [TEST HOOK] before_tool_call fired for: exec
Hook executes ✅
14:15:05 [TEST HOOK] Returning { cancel: true }
Hook returns cancellation ✅
14:15:05 plugins [hooks] running before_tool_call (1 handlers, sequential)
System acknowledges hook ran
14:15:05 agent/embedded embedded run tool start: runId=e442175e-004c-4f05-9202-062163708228 tool=exec
Here is the problem !
🔴 TOOL EXECUTES ANYWAY — despite { cancel: true }
14:15:10 [tools] exec failed: Command timed out after 5 seconds
Tool completes (error, but still ran)
14:15:10 plugins [hooks] running after_tool_call
After-hook fires (too late, tool already ran)
[test-hook-plugin.zip](https://github.com/user-attachments/files/25367045/test-hook-plugin.zip)
Impact and severity
Affected: All users
Severity: High - no enforceable policies, data risk and exfiliaiton
Frequency: 100%
Consequence: No control over tool calls
Additional information
Remediation
The minimal fix is changing the hook dispatch from fire-and-forget to awaited in the tool execution path. Pseudocode:
// Current (fire-and-forget):
void runBeforeToolCallHook(event).catch(() => {});
const result = await tool.execute(args);
void runAfterToolCallHook({ ...event, result }).catch(() => {});
// Proposed (awaited):
const hookResult = await runBeforeToolCallHook(event);
if (hookResult?.cancel) {
return { error: hookResult.blockReason ?? "Blocked by security policy" };
}
const result = await tool.execute(args);
await runAfterToolCallHook({ ...event, result });
// result may have been mutated by hook handlers
Summary
Plugin hooks
before_tool_callandafter_tool_callare dispatched fire-and-forget (void runHook(...).catch()). A handler returning{ cancel: true }does not prevent tool execution — the return value is discarded before evaluation.This breaks the API contract defined in
BeforeToolCallHookResultand prevents security extensions from enforcing tool execution policy. The only awaited hook,message_sending, correctly honors its return values.fixing this (awaiting the hook) has zero cost when no plugin registers a handler. The hook runner already checks for registered handlers — awaiting an empty list resolves one microtick (~nanoseconds). Latency only applies when a user has explicitly installed a security plugin.
Steps to reproduce
Small test plugin already created test-hook-plugin.zip
Create a plugin that registers a
before_tool_callhook:api.on("before_tool_call", async (event) => {
console.log(
Hook fired for: ${event.toolName});return { cancel: true };
});
Install the plugin and trigger any tool call (e.g., web_search or exec)
Observe: hook fires (log appears), but the tool executes anyway
Compare with
message_sending— returning{ cancel: true }thereactually cancels the message because it's the only awaited hook
Expected behavior
before_tool_callreturning{ cancel: true }should prevent the tool from executing, matching the semantics defined in theBeforeToolCallHookResulttype. Similarly,after_tool_callmutatingevent.resultshould change what the LLM sees, since the type declaresresultas mutable.This matches how
message_sendingalready works — it's awaited, and its return values (cancel, content modification) are honored.Actual behavior
Both hooks are dispatched as fire-and-forget. The tool execution path does not await the hook result, so:
before_tool_call:{ cancel: true }is returned but never read — tool runs regardlessafter_tool_call:resultmutations happen after the LLM has already received the raw outputThe type system promises enforcement semantics that the runtime does not deliver. 6+ independent security projects (PRs #6095, #14222, #8448, and external projects ClawBands, openclaw-harness, openclaw-shield) have independently hit this gap trying to build tool execution policy enforcement.
OpenClaw version
2026.2.15
Operating system
Ubuntu 24.04
Install method
npm global
Logs, screenshots, and evidence
Impact and severity
Affected: All users
Severity: High - no enforceable policies, data risk and exfiliaiton
Frequency: 100%
Consequence: No control over tool calls
Additional information
Remediation
The minimal fix is changing the hook dispatch from fire-and-forget to awaited in the tool execution path. Pseudocode:
// Current (fire-and-forget):
void runBeforeToolCallHook(event).catch(() => {});
const result = await tool.execute(args);
void runAfterToolCallHook({ ...event, result }).catch(() => {});
// Proposed (awaited):
const hookResult = await runBeforeToolCallHook(event);
if (hookResult?.cancel) {
return { error: hookResult.blockReason ?? "Blocked by security policy" };
}
const result = await tool.execute(args);
await runAfterToolCallHook({ ...event, result });
// result may have been mutated by hook handlers