Skip to content

[Bug]: Security - before_tool_call hook return values ({ cancel: true }) are silently discarded — tool executes regardless #19231

Description

@gstudiogdesigns-max

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

  1. 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 };
    });

  2. Install the plugin and trigger any tool call (e.g., web_search or exec)

  3. Observe: hook fires (log appears), but the tool executes anyway

  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions