Skip to content

Commit e19dce0

Browse files
fix(hooks): harden before_tool_call hook runner to fail-closed on error [AI] (#59822)
* fix: address issue * fix: address PR review feedback * docs: add changelog entry for PR merge * docs: normalize changelog entry placement --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
1 parent 1322aa2 commit e19dce0

7 files changed

Lines changed: 82 additions & 15 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
9494
- Agents/workspace: respect `agents.defaults.workspace` for non-default agents by resolving them under the configured base path instead of falling back to `workspace-<id>`. (#59858) Thanks @joelnishanth.
9595
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the snapshot during redaction. (#28214) thanks @solodmd.
9696
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
97+
- Hooks/tool policy: block tool calls when a `before_tool_call` hook crashes so hook failures fail closed instead of silently allowing execution. (#59822) Thanks @pgondhi987.
9798

9899
## 2026.4.2
99100

src/agents/pi-tools.before-tool-call.e2e.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,22 @@ describe("before_tool_call requireApproval handling", () => {
390390
expect(mockCallGateway).not.toHaveBeenCalled();
391391
});
392392

393+
it("blocks when before_tool_call hook execution throws", async () => {
394+
hookRunner.runBeforeToolCall.mockRejectedValueOnce(new Error("hook crashed"));
395+
396+
const result = await runBeforeToolCallHook({
397+
toolName: "bash",
398+
params: { command: "ls" },
399+
ctx: { agentId: "main", sessionKey: "main" },
400+
});
401+
402+
expect(result.blocked).toBe(true);
403+
expect(result).toHaveProperty(
404+
"reason",
405+
"Tool call blocked because before_tool_call hook failed",
406+
);
407+
});
408+
393409
it("calls gateway RPC and unblocks on allow-once", async () => {
394410
hookRunner.runBeforeToolCall.mockResolvedValue({
395411
requireApproval: {

src/agents/pi-tools.before-tool-call.integration.e2e.test.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ describe("before_tool_call hook integration", () => {
167167
expect(execute).not.toHaveBeenCalled();
168168
});
169169

170-
it("continues execution when hook throws", async () => {
170+
it("blocks tool execution when hook throws", async () => {
171171
beforeToolCallHook = installBeforeToolCallHook({
172172
runBeforeToolCallImpl: async () => {
173173
throw new Error("boom");
@@ -178,14 +178,10 @@ describe("before_tool_call hook integration", () => {
178178
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any);
179179
const extensionContext = {} as Parameters<typeof tool.execute>[3];
180180

181-
await tool.execute("call-4", { path: "/tmp/file" }, undefined, extensionContext);
182-
183-
expect(execute).toHaveBeenCalledWith(
184-
"call-4",
185-
{ path: "/tmp/file" },
186-
undefined,
187-
extensionContext,
188-
);
181+
await expect(
182+
tool.execute("call-4", { path: "/tmp/file" }, undefined, extensionContext),
183+
).rejects.toThrow("Tool call blocked because before_tool_call hook failed");
184+
expect(execute).not.toHaveBeenCalled();
189185
});
190186

191187
it("normalizes non-object params for hook contract", async () => {

src/agents/pi-tools.before-tool-call.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ type HookOutcome = { blocked: true; reason: string } | { blocked: false; params:
2424

2525
const log = createSubsystemLogger("agents/tools");
2626
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
27+
const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON =
28+
"Tool call blocked because before_tool_call hook failed";
2729
const adjustedParamsByToolCallId = new Map<string, unknown>();
2830
const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
2931
const LOOP_WARNING_BUCKET_SIZE = 10;
@@ -67,6 +69,13 @@ function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean
6769
return false;
6870
}
6971

72+
function unwrapErrorCause(err: unknown): unknown {
73+
if (err instanceof Error && err.cause !== undefined) {
74+
return err.cause;
75+
}
76+
return err;
77+
}
78+
7079
function shouldEmitLoopWarning(state: SessionState, warningKey: string, count: number): boolean {
7180
if (!state.toolLoopWarningBuckets) {
7281
state.toolLoopWarningBuckets = new Map();
@@ -357,7 +366,12 @@ export async function runBeforeToolCallHook(args: {
357366
}
358367
} catch (err) {
359368
const toolCallId = args.toolCallId ? ` toolCallId=${args.toolCallId}` : "";
360-
log.warn(`before_tool_call hook failed: tool=${toolName}${toolCallId} error=${String(err)}`);
369+
const cause = unwrapErrorCause(err);
370+
log.error(`before_tool_call hook failed: tool=${toolName}${toolCallId} error=${String(cause)}`);
371+
return {
372+
blocked: true,
373+
reason: BEFORE_TOOL_CALL_HOOK_FAILURE_REASON,
374+
};
361375
}
362376

363377
return { blocked: false, params };

src/plugins/hook-runner-global.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export function initializeGlobalHookRunner(registry: PluginRegistry): void {
4040
error: (msg) => log.error(msg),
4141
},
4242
catchErrors: true,
43+
failurePolicyByHook: {
44+
before_tool_call: "fail-closed",
45+
},
4346
});
4447

4548
const hookCount = registry.hooks.length;

src/plugins/hooks.security.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,32 @@ describe("before_tool_call terminal block semantics", () => {
163163
expect(result?.block).toBe(true);
164164
expect(low).not.toHaveBeenCalled();
165165
});
166+
167+
it("throws for before_tool_call when configured as fail-closed", async () => {
168+
addStaticTestHooks(registry, {
169+
hookName: "before_tool_call",
170+
hooks: [
171+
{
172+
pluginId: "failing",
173+
result: {},
174+
priority: 100,
175+
handler: () => {
176+
throw new Error("boom");
177+
},
178+
},
179+
],
180+
});
181+
const runner = createHookRunner(registry, {
182+
catchErrors: true,
183+
failurePolicyByHook: {
184+
before_tool_call: "fail-closed",
185+
},
186+
});
187+
188+
await expect(runner.runBeforeToolCall(toolEvent, toolCtx)).rejects.toThrow(
189+
"before_tool_call handler from failing failed: Error: boom",
190+
);
191+
});
166192
});
167193

168194
describe("message_sending terminal cancel semantics", () => {

src/plugins/hooks.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,17 @@ export type HookRunnerLogger = {
124124
error: (message: string) => void;
125125
};
126126

127+
export type HookFailurePolicy = "fail-open" | "fail-closed";
128+
127129
export type HookRunnerOptions = {
128130
logger?: HookRunnerLogger;
129131
/** If true, errors in hooks will be caught and logged instead of thrown */
130132
catchErrors?: boolean;
133+
/**
134+
* Optional per-hook failure policy.
135+
* Defaults to fail-open unless explicitly overridden for a hook name.
136+
*/
137+
failurePolicyByHook?: Partial<Record<PluginHookName, HookFailurePolicy>>;
131138
};
132139

133140
type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
@@ -186,6 +193,10 @@ function getHooksForNameAndPlugin<K extends PluginHookName>(
186193
export function createHookRunner(registry: PluginRegistry, options: HookRunnerOptions = {}) {
187194
const logger = options.logger;
188195
const catchErrors = options.catchErrors ?? true;
196+
const failurePolicyByHook = options.failurePolicyByHook ?? {};
197+
198+
const shouldCatchHookErrors = (hookName: PluginHookName): boolean =>
199+
catchErrors && (failurePolicyByHook[hookName] ?? "fail-open") === "fail-open";
189200

190201
const firstDefined = <T>(prev: T | undefined, next: T | undefined): T | undefined => prev ?? next;
191202
const lastDefined = <T>(prev: T | undefined, next: T | undefined): T | undefined => next ?? prev;
@@ -255,7 +266,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
255266
const msg = `[hooks] ${params.hookName} handler from ${params.pluginId} failed: ${String(
256267
params.error,
257268
)}`;
258-
if (catchErrors) {
269+
if (shouldCatchHookErrors(params.hookName)) {
259270
logger?.error(msg);
260271
return;
261272
}
@@ -797,7 +808,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
797808
const msg =
798809
`[hooks] tool_result_persist handler from ${hook.pluginId} returned a Promise; ` +
799810
`this hook is synchronous and the result was ignored.`;
800-
if (catchErrors) {
811+
if (shouldCatchHookErrors("tool_result_persist")) {
801812
logger?.warn?.(msg);
802813
continue;
803814
}
@@ -810,7 +821,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
810821
}
811822
} catch (err) {
812823
const msg = `[hooks] tool_result_persist handler from ${hook.pluginId} failed: ${String(err)}`;
813-
if (catchErrors) {
824+
if (shouldCatchHookErrors("tool_result_persist")) {
814825
logger?.error(msg);
815826
} else {
816827
throw new Error(msg, { cause: err });
@@ -862,7 +873,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
862873
const msg =
863874
`[hooks] before_message_write handler from ${hook.pluginId} returned a Promise; ` +
864875
`this hook is synchronous and the result was ignored.`;
865-
if (catchErrors) {
876+
if (shouldCatchHookErrors("before_message_write")) {
866877
logger?.warn?.(msg);
867878
continue;
868879
}
@@ -882,7 +893,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
882893
}
883894
} catch (err) {
884895
const msg = `[hooks] before_message_write handler from ${hook.pluginId} failed: ${String(err)}`;
885-
if (catchErrors) {
896+
if (shouldCatchHookErrors("before_message_write")) {
886897
logger?.error(msg);
887898
} else {
888899
throw new Error(msg, { cause: err });

0 commit comments

Comments
 (0)