feat(agents): emit security events for tool vetoes

This commit is contained in:
Vincent Koc
2026-06-16 17:49:07 +08:00
parent f3a1d1fcb0
commit d491018a45
2 changed files with 101 additions and 0 deletions
@@ -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<DiagnosticEventPayload, { type: "security.event" }> =>
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(
{},
@@ -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,