diff --git a/src/agents/agent-tools.before-tool-call.e2e.test.ts b/src/agents/agent-tools.before-tool-call.e2e.test.ts index 0074d2679035..9e11d5ca3393 100644 --- a/src/agents/agent-tools.before-tool-call.e2e.test.ts +++ b/src/agents/agent-tools.before-tool-call.e2e.test.ts @@ -824,6 +824,59 @@ describe("before_tool_call loop detection behavior", () => { }); }); + it("emits a security event for intentional hook vetoes", async () => { + hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call"); + hookRunner.runBeforeToolCall.mockResolvedValue({ + block: true, + blockReason: "blocked by policy", + }); + const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "nope" }] }); + const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, { + agentId: "main", + sessionKey: "session-key", + loopDetection: { enabled: false }, + }); + + await withDiagnosticEvents(async (emitted, flush) => { + await tool.execute("tool-call-blocked", { path: "/tmp/file" }); + await flush(); + + const securityEvent = emitted.find( + (event): event is Extract => + event.type === "security.event", + ); + expect(securityEvent).toMatchObject({ + type: "security.event", + category: "tool", + action: "tool.execution.blocked", + outcome: "denied", + severity: "medium", + reason: "plugin-before-tool-call", + actor: { kind: "agent" }, + target: { + kind: "tool", + name: "read", + }, + policy: { + id: "plugin-before-tool-call", + decision: "deny", + reason: "plugin-before-tool-call", + }, + control: { + id: "before-tool-call", + family: "approval", + }, + attributes: { + params_kind: "object", + tool_source: "core", + }, + }); + expect(securityEvent?.eventId).toBeTypeOf("string"); + expect(JSON.stringify(securityEvent)).not.toContain("/tmp/file"); + expect(emitted.some((event) => event.type === "tool.execution.blocked")).toBe(true); + }); + }); + it("does not let hostile thrown values break diagnostic error emission", async () => { const hostileError = new Proxy( {}, diff --git a/src/agents/agent-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts index fac13aee4410..c7667f54cb0a 100644 --- a/src/agents/agent-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -15,6 +15,7 @@ import { import { emitTrustedDiagnosticEvent, emitTrustedDiagnosticEventWithPrivateData, + emitTrustedSecurityEvent, type DiagnosticEventPrivateData, type DiagnosticToolParamsSummary, type DiagnosticToolSource, @@ -566,6 +567,45 @@ function emitSkillUsedDiagnostic(params: { }); } +function emitToolBlockedSecurityEvent(params: { + ctx?: HookContext; + deniedReason: string; + toolIdentity: ToolDiagnosticIdentity; + toolName: string; + trace?: DiagnosticTraceContext; + paramsSummary?: DiagnosticToolParamsSummary; +}): void { + emitTrustedSecurityEvent({ + category: "tool", + action: "tool.execution.blocked", + outcome: "denied", + severity: "medium", + reason: params.deniedReason, + ...(params.trace ? { trace: params.trace } : {}), + actor: { + kind: "agent", + }, + target: { + kind: "tool", + name: params.toolName, + ...(params.toolIdentity.toolOwner ? { owner: params.toolIdentity.toolOwner } : {}), + }, + policy: { + id: "plugin-before-tool-call", + decision: "deny", + reason: params.deniedReason, + }, + control: { + id: "before-tool-call", + family: "approval", + }, + attributes: { + tool_source: params.toolIdentity.toolSource, + ...(params.paramsSummary ? { params_kind: params.paramsSummary.kind } : {}), + }, + }); +} + function notifyPluginApprovalResolution( approval: PluginApprovalRequest, resolution: PluginApprovalResolution, @@ -1353,6 +1393,14 @@ export function wrapToolWithBeforeToolCallHook( reason: outcome.reason, deniedReason: outcome.deniedReason ?? "plugin-before-tool-call", }); + emitToolBlockedSecurityEvent({ + ctx, + deniedReason: outcome.deniedReason ?? "plugin-before-tool-call", + toolIdentity: diagnosticIdentity, + toolName: normalizedToolName, + trace, + paramsSummary: eventBase.paramsSummary, + }); } const blockedResult = buildBlockedToolResult({ reason: outcome.reason,