Skip to content

Commit e0cb2c4

Browse files
committed
feat(core): add agent tool authorization guard
Add an Agent-level toolGuard hook that can deny local tool execution before the tool runs. Denied calls reuse the existing ToolDeniedError path so tool error/end hooks still receive audit context.\n\nAdds behavior and type coverage for the new guard API.\n\nRelated to #1177.
1 parent 3377f6d commit e0cb2c4

5 files changed

Lines changed: 168 additions & 3 deletions

File tree

packages/core/src/agent/agent.spec-d.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,32 @@ describe("Agent Type System", () => {
628628
expectTypeOf(hooks).toMatchTypeOf<AgentHooks>();
629629
});
630630

631+
it("should validate agent toolGuard option", () => {
632+
const agentOptions: AgentOptions = {
633+
name: "GuardedAgent",
634+
instructions: "Test",
635+
model: "openai/gpt-4o-mini",
636+
toolGuard: async ({ agent, tool, args, context }) => {
637+
expectTypeOf(agent).toMatchTypeOf<Agent>();
638+
expectTypeOf(tool.name).toEqualTypeOf<string>();
639+
expectTypeOf(args).toBeAny();
640+
expectTypeOf(context).toMatchTypeOf<OperationContext>();
641+
return { denied: true, reason: "read-only" };
642+
},
643+
};
644+
645+
expectTypeOf(agentOptions).toMatchTypeOf<AgentOptions>();
646+
647+
const booleanGuardOptions: AgentOptions = {
648+
name: "BooleanGuardAgent",
649+
instructions: "Test",
650+
model: "openai/gpt-4o-mini",
651+
toolGuard: () => true,
652+
};
653+
654+
expectTypeOf(booleanGuardOptions).toMatchTypeOf<AgentOptions>();
655+
});
656+
631657
it("should allow sync and async hooks", () => {
632658
const syncHooks: AgentHooks = {
633659
onStart: () => {

packages/core/src/agent/agent.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,6 +1489,77 @@ Use pandas and summarize findings.`.split("\n"),
14891489
operationContext.traceContext.end("completed");
14901490
});
14911491

1492+
it("blocks tool execution when toolGuard denies the tool", async () => {
1493+
const execute = vi.fn().mockResolvedValue("should-not-run");
1494+
const toolGuard = vi.fn().mockResolvedValue({
1495+
denied: true,
1496+
reason: "read-only agent",
1497+
});
1498+
const onToolError = vi.fn();
1499+
const onToolEnd = vi.fn();
1500+
const agent = new Agent({
1501+
name: "TestAgent",
1502+
instructions: "Test",
1503+
model: mockModel as any,
1504+
toolGuard,
1505+
hooks: createHooks({ onToolError, onToolEnd }),
1506+
});
1507+
1508+
const protectedTool = new Tool({
1509+
name: "delete-note",
1510+
description: "Deletes a note",
1511+
parameters: z.object({ id: z.string() }),
1512+
execute,
1513+
});
1514+
1515+
const operationContext = (agent as any).createOperationContext("input");
1516+
const executeFactory = (agent as any).createToolExecutionFactory(
1517+
operationContext,
1518+
agent.hooks,
1519+
);
1520+
1521+
const result = await executeFactory(protectedTool)({ id: "note-1" });
1522+
1523+
expect(execute).not.toHaveBeenCalled();
1524+
expect(toolGuard).toHaveBeenCalledWith(
1525+
expect.objectContaining({
1526+
agent,
1527+
tool: protectedTool,
1528+
args: { id: "note-1" },
1529+
context: operationContext,
1530+
}),
1531+
);
1532+
expect(result).toMatchObject({
1533+
error: true,
1534+
toolName: "delete-note",
1535+
code: "TOOL_FORBIDDEN",
1536+
});
1537+
expect(result.message).toContain("read-only agent");
1538+
expect(onToolError).toHaveBeenCalledTimes(1);
1539+
const toolErrorArgs = onToolError.mock.calls[0][0];
1540+
expect(toolErrorArgs.tool).toBe(protectedTool);
1541+
expect(toolErrorArgs.args).toEqual({ id: "note-1" });
1542+
expect(toolErrorArgs.originalError).toMatchObject({
1543+
code: "TOOL_FORBIDDEN",
1544+
message: "read-only agent",
1545+
});
1546+
expect(toolErrorArgs.error).toMatchObject({
1547+
message: "read-only agent",
1548+
stage: "tool_execution",
1549+
});
1550+
1551+
expect(onToolEnd).toHaveBeenCalledTimes(1);
1552+
const toolEndArgs = onToolEnd.mock.calls[0][0];
1553+
expect(toolEndArgs.tool).toBe(protectedTool);
1554+
expect(toolEndArgs.output).toBeUndefined();
1555+
expect(toolEndArgs.error).toMatchObject({
1556+
message: "read-only agent",
1557+
stage: "tool_execution",
1558+
});
1559+
1560+
operationContext.traceContext.end("completed");
1561+
});
1562+
14921563
it("calls onToolError when a tool throws", async () => {
14931564
const onToolError = vi.fn();
14941565
const onToolEnd = vi.fn();

packages/core/src/agent/agent.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,12 @@ import {
106106
type EnqueueEvalScoringArgs,
107107
enqueueEvalScoring as enqueueEvalScoringHelper,
108108
} from "./eval";
109-
import type { AgentHooks, OnToolEndHookResult, OnToolErrorHookResult } from "./hooks";
109+
import type {
110+
AgentHooks,
111+
AgentToolGuard,
112+
OnToolEndHookResult,
113+
OnToolErrorHookResult,
114+
} from "./hooks";
110115
import { stripDanglingOpenAIReasoningFromModelMessages } from "./model-message-normalizer";
111116
import { AgentTraceContext, addModelAttributesToSpan } from "./open-telemetry/trace-context";
112117
import {
@@ -1044,6 +1049,7 @@ export class Agent {
10441049
private readonly workspaceToolkitOptions: AgentOptions["workspaceToolkits"];
10451050
private readonly workspaceSkillsPromptOption: AgentOptions["workspaceSkillsPrompt"];
10461051
private readonly configuredHooks?: AgentHooks;
1052+
private readonly toolGuard?: AgentToolGuard;
10471053
private readonly maxStepsConfigured: boolean;
10481054
private defaultObservability?: VoltAgentObservability;
10491055
private readonly toolManager: ToolManager;
@@ -1078,6 +1084,7 @@ export class Agent {
10781084
this.workspaceToolkitOptions = options.workspaceToolkits;
10791085
this.workspaceSkillsPromptOption = options.workspaceSkillsPrompt;
10801086
this.configuredHooks = options.hooks;
1087+
this.toolGuard = options.toolGuard;
10811088
this.maxStepsConfigured = options.maxSteps !== undefined;
10821089
const globalWorkspace = AgentRegistry.getInstance().getGlobalWorkspace();
10831090
const workspaceOption = options.workspace === undefined ? globalWorkspace : options.workspace;
@@ -6421,6 +6428,46 @@ export class Agent {
64216428
return parseResult.data;
64226429
}
64236430

6431+
private async assertToolGuardAllows(
6432+
tool: BaseTool | ProviderTool,
6433+
args: any,
6434+
oc: OperationContext,
6435+
options?: ToolExecuteOptions,
6436+
): Promise<void> {
6437+
if (!this.toolGuard) {
6438+
return;
6439+
}
6440+
6441+
const result = await this.toolGuard({
6442+
agent: this,
6443+
tool: tool as any,
6444+
context: oc,
6445+
args,
6446+
options,
6447+
});
6448+
6449+
const denied =
6450+
result === false ||
6451+
(typeof result === "object" &&
6452+
result !== null &&
6453+
(result.denied === true || result.allowed === false));
6454+
if (!denied) {
6455+
return;
6456+
}
6457+
6458+
const reason =
6459+
typeof result === "object" && result !== null && typeof result.reason === "string"
6460+
? result.reason
6461+
: "Tool execution denied by toolGuard.";
6462+
6463+
throw new ToolDeniedError({
6464+
toolName: tool.name,
6465+
message: reason,
6466+
code: "TOOL_FORBIDDEN",
6467+
httpStatus: 403,
6468+
});
6469+
}
6470+
64246471
private createToolExecutionFactory(
64256472
oc: OperationContext,
64266473
hooks: AgentHooks,
@@ -6623,6 +6670,7 @@ export class Agent {
66236670
try {
66246671
await this.waitForSpeculativeInputGuardrail(oc);
66256672
await oc.traceContext.withSpan(toolSpan, async () => {
6673+
await this.assertToolGuardAllows(tool, args, oc, executionOptions);
66266674
await runToolStartHooks();
66276675
});
66286676

@@ -6683,7 +6731,8 @@ export class Agent {
66836731
return oc.traceContext.withSpan(toolSpan, async () => {
66846732
try {
66856733
await this.waitForSpeculativeInputGuardrail(oc);
6686-
// Call tool start hook - can throw ToolDeniedError
6734+
// Call tool guard and start hook - both can throw ToolDeniedError
6735+
await this.assertToolGuardAllows(tool, args, oc, executionOptions);
66876736
await runToolStartHooks();
66886737

66896738
// Execute tool with merged options
@@ -7242,6 +7291,7 @@ export class Agent {
72427291
`Provider tool "${tool.name}" received arguments that do not match callTool input.`,
72437292
);
72447293
}
7294+
await this.assertToolGuardAllows(tool, callInput, oc, executionOptions);
72457295
await hooks.onToolStart?.({
72467296
agent: this,
72477297
tool: tool as any,

packages/core/src/agent/hooks/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ export interface OnToolStartHookArgs {
5959
options?: ToolExecuteOptions;
6060
}
6161

62+
export interface ToolGuardArgs extends OnToolStartHookArgs {}
63+
64+
export type ToolGuardResult =
65+
| boolean
66+
| {
67+
allowed?: boolean;
68+
denied?: boolean;
69+
reason?: string;
70+
}
71+
| undefined;
72+
73+
export type AgentToolGuard = (args: ToolGuardArgs) => Promise<ToolGuardResult> | ToolGuardResult;
74+
6275
export interface OnToolEndHookArgs {
6376
agent: Agent;
6477
tool: AgentTool;

packages/core/src/agent/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import type {
5555
WorkspaceSkillsToolkitOptions,
5656
} from "../workspace";
5757
import type { ContextInput } from "./agent";
58-
import type { AgentHooks } from "./hooks";
58+
import type { AgentHooks, AgentToolGuard } from "./hooks";
5959
import type { AgentTraceContext } from "./open-telemetry/trace-context";
6060

6161
// Re-export for backward compatibility
@@ -717,6 +717,11 @@ export type AgentOptions = {
717717

718718
// Hooks
719719
hooks?: AgentHooks;
720+
/**
721+
* Optional per-tool authorization guard.
722+
* Return `false`, `{ allowed: false }`, or `{ denied: true }` to block execution.
723+
*/
724+
toolGuard?: AgentToolGuard;
720725

721726
// Guardrails
722727
inputGuardrails?: InputGuardrail[];

0 commit comments

Comments
 (0)