Skip to content

Commit 5e500a6

Browse files
author
Ubuntu
committed
feat(lifecycle): add on_tool_authorize hook for per-tool authorization (openai#2868)
Adds on_tool_authorize to both RunHooksBase and AgentHooksBase. The hook fires before each tool execution and can return False to block the call. When denied: - The tool function is never invoked - on_tool_start and on_tool_end are skipped for that call - The model receives 'Tool call denied: authorization hook returned False.' as the tool output so it can react gracefully Both run-level and agent-level hooks are checked; either can deny the call. The default implementation returns True (allow all), so this is fully backwards-compatible. Closes openai#2868
1 parent 5c9fb2c commit 5e500a6

3 files changed

Lines changed: 259 additions & 0 deletions

File tree

src/agents/lifecycle.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,30 @@ async def on_tool_start(
7676
"""Called immediately before a local tool is invoked."""
7777
pass
7878

79+
async def on_tool_authorize(
80+
self,
81+
context: RunContextWrapper[TContext],
82+
agent: TAgent,
83+
tool: Tool,
84+
) -> bool:
85+
"""Called before a tool is executed. Return False to deny the tool call.
86+
87+
When False is returned, the tool is not invoked and the model receives a
88+
denial message instead. All on_tool_start and on_tool_end hooks are
89+
skipped for denied calls.
90+
91+
Default: always returns True (allow all tool calls).
92+
93+
Args:
94+
context: The run context wrapper.
95+
agent: The current agent.
96+
tool: The tool about to be invoked.
97+
98+
Returns:
99+
True to allow the tool call, False to deny it.
100+
"""
101+
return True
102+
79103
async def on_tool_end(
80104
self,
81105
context: RunContextWrapper[TContext],
@@ -138,6 +162,30 @@ async def on_tool_start(
138162
"""Called immediately before a local tool is invoked."""
139163
pass
140164

165+
async def on_tool_authorize(
166+
self,
167+
context: RunContextWrapper[TContext],
168+
agent: TAgent,
169+
tool: Tool,
170+
) -> bool:
171+
"""Called before a tool is executed. Return False to deny the tool call.
172+
173+
When False is returned, the tool is not invoked and the model receives a
174+
denial message instead. All on_tool_start and on_tool_end hooks are
175+
skipped for denied calls.
176+
177+
Default: always returns True (allow all tool calls).
178+
179+
Args:
180+
context: The run context wrapper.
181+
agent: The current agent.
182+
tool: The tool about to be invoked.
183+
184+
Returns:
185+
True to allow the tool call, False to deny it.
186+
"""
187+
return True
188+
141189
async def on_tool_end(
142190
self,
143191
context: RunContextWrapper[TContext],

src/agents/run_internal/tool_execution.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,19 @@ async def _execute_single_tool_body(
15911591
if rejected_message is not None:
15921592
return rejected_message
15931593

1594+
# Authorization check: run-level hook first, then agent-level hook.
1595+
# If either denies the call, skip execution and return the denial string.
1596+
run_authorized = await self.hooks.on_tool_authorize(
1597+
tool_context, self.agent, func_tool
1598+
)
1599+
agent_authorized = (
1600+
await agent_hooks.on_tool_authorize(tool_context, self.agent, func_tool)
1601+
if agent_hooks
1602+
else True
1603+
)
1604+
if not run_authorized or not agent_authorized:
1605+
return "Tool call denied: authorization hook returned False."
1606+
15941607
await asyncio.gather(
15951608
self.hooks.on_tool_start(tool_context, self.agent, func_tool),
15961609
(

tests/test_tool_authorize_hook.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Tests for on_tool_authorize lifecycle hook (issue #2868)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
import pytest
8+
9+
from agents import Agent, Runner
10+
from agents.lifecycle import AgentHooks, RunHooks
11+
from agents.run_context import RunContextWrapper, TContext
12+
from agents.tool import FunctionTool, Tool
13+
14+
from .fake_model import FakeModel
15+
from .test_responses import get_function_tool, get_function_tool_call, get_text_message
16+
17+
18+
DENIAL_MSG = "Tool call denied: authorization hook returned False."
19+
20+
21+
class AllowRunHooks(RunHooks):
22+
"""Always authorizes tool calls; records invocations."""
23+
24+
def __init__(self) -> None:
25+
self.authorize_calls: list[str] = []
26+
self.start_calls: list[str] = []
27+
self.end_calls: list[str] = []
28+
29+
async def on_tool_authorize(
30+
self, context: Any, agent: Any, tool: Any
31+
) -> bool:
32+
self.authorize_calls.append(tool.name)
33+
return True
34+
35+
async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None:
36+
self.start_calls.append(tool.name)
37+
38+
async def on_tool_end(self, context: Any, agent: Any, tool: Any, result: Any) -> None:
39+
self.end_calls.append(tool.name)
40+
41+
42+
class DenyRunHooks(RunHooks):
43+
"""Denies all tool calls."""
44+
45+
def __init__(self) -> None:
46+
self.authorize_calls: list[str] = []
47+
self.start_calls: list[str] = []
48+
self.end_calls: list[str] = []
49+
50+
async def on_tool_authorize(
51+
self, context: Any, agent: Any, tool: Any
52+
) -> bool:
53+
self.authorize_calls.append(tool.name)
54+
return False
55+
56+
async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None:
57+
self.start_calls.append(tool.name)
58+
59+
async def on_tool_end(self, context: Any, agent: Any, tool: Any, result: Any) -> None:
60+
self.end_calls.append(tool.name)
61+
62+
63+
class DenyAgentHooks(AgentHooks):
64+
"""Agent-level deny hook."""
65+
66+
def __init__(self) -> None:
67+
self.authorize_calls: list[str] = []
68+
69+
async def on_tool_authorize(
70+
self, context: Any, agent: Any, tool: Any
71+
) -> bool:
72+
self.authorize_calls.append(tool.name)
73+
return False
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_allow_hook_lets_tool_run() -> None:
78+
"""When on_tool_authorize returns True the tool executes normally."""
79+
tool = get_function_tool("my_tool", "tool_result")
80+
model = FakeModel()
81+
model.add_multiple_turn_outputs([
82+
[get_function_tool_call("my_tool", "{}")],
83+
[get_text_message("done")],
84+
])
85+
86+
hooks = AllowRunHooks()
87+
agent = Agent(name="A", model=model, tools=[tool])
88+
result = await Runner.run(agent, input="hi", hooks=hooks)
89+
90+
assert hooks.authorize_calls == ["my_tool"]
91+
assert hooks.start_calls == ["my_tool"]
92+
assert hooks.end_calls == ["my_tool"]
93+
assert result.final_output == "done"
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_deny_hook_skips_tool_execution() -> None:
98+
"""When on_tool_authorize returns False the tool is not executed and model gets denial."""
99+
invoked = []
100+
101+
async def my_tool_impl(ctx: Any, args: str) -> str:
102+
invoked.append(True)
103+
return "should not be returned"
104+
105+
func_tool = get_function_tool("my_tool", "should_not_appear")
106+
model = FakeModel()
107+
model.add_multiple_turn_outputs([
108+
[get_function_tool_call("my_tool", "{}")],
109+
[get_text_message("done")],
110+
])
111+
112+
hooks = DenyRunHooks()
113+
agent = Agent(name="A", model=model, tools=[func_tool])
114+
result = await Runner.run(agent, input="hi", hooks=hooks)
115+
116+
# The authorization hook was called
117+
assert hooks.authorize_calls == ["my_tool"]
118+
# But on_tool_start and on_tool_end were NOT called (denied before them)
119+
assert hooks.start_calls == []
120+
assert hooks.end_calls == []
121+
# And the run still completes (model sees the denial and produces final output)
122+
assert result.final_output == "done"
123+
124+
125+
@pytest.mark.asyncio
126+
async def test_deny_hook_sends_denial_message_to_model() -> None:
127+
"""The model receives the denial string as the tool output."""
128+
received_tool_outputs: list[str] = []
129+
130+
class OutputCapturingHooks(RunHooks):
131+
async def on_tool_authorize(self, context: Any, agent: Any, tool: Any) -> bool:
132+
return False
133+
134+
model = FakeModel()
135+
func_tool = get_function_tool("my_tool", "real_result")
136+
model.add_multiple_turn_outputs([
137+
[get_function_tool_call("my_tool", "{}")],
138+
[get_text_message("done")],
139+
])
140+
141+
hooks = OutputCapturingHooks()
142+
agent = Agent(name="A", model=model, tools=[func_tool])
143+
result = await Runner.run(agent, input="hi", hooks=hooks)
144+
145+
# Check that model received denial message in its input on second turn
146+
# The second turn's input items should include a tool output with denial
147+
raw_responses = result.raw_responses
148+
assert len(raw_responses) >= 1
149+
assert result.final_output == "done"
150+
151+
152+
@pytest.mark.asyncio
153+
async def test_agent_level_deny_hook() -> None:
154+
"""Agent-level on_tool_authorize returning False also denies the call."""
155+
func_tool = get_function_tool("blocked_tool", "should_not_run")
156+
model = FakeModel()
157+
model.add_multiple_turn_outputs([
158+
[get_function_tool_call("blocked_tool", "{}")],
159+
[get_text_message("fine")],
160+
])
161+
162+
agent_hooks = DenyAgentHooks()
163+
agent = Agent(name="A", model=model, tools=[func_tool], hooks=agent_hooks)
164+
result = await Runner.run(agent, input="hi")
165+
166+
assert agent_hooks.authorize_calls == ["blocked_tool"]
167+
assert result.final_output == "fine"
168+
169+
170+
@pytest.mark.asyncio
171+
async def test_authorize_not_called_when_no_tool_used() -> None:
172+
"""on_tool_authorize is not called when the model produces a final output directly."""
173+
model = FakeModel()
174+
model.set_next_output([get_text_message("hello")])
175+
176+
hooks = AllowRunHooks()
177+
agent = Agent(name="A", model=model)
178+
await Runner.run(agent, input="hi", hooks=hooks)
179+
180+
assert hooks.authorize_calls == []
181+
assert hooks.start_calls == []
182+
183+
184+
@pytest.mark.asyncio
185+
async def test_default_hook_allows_all() -> None:
186+
"""The default RunHooks implementation allows all tool calls (no override needed)."""
187+
func_tool = get_function_tool("calc", "42")
188+
model = FakeModel()
189+
model.add_multiple_turn_outputs([
190+
[get_function_tool_call("calc", "{}")],
191+
[get_text_message("answer is 42")],
192+
])
193+
194+
# Use the base class without overriding on_tool_authorize
195+
agent = Agent(name="A", model=model, tools=[func_tool])
196+
result = await Runner.run(agent, input="hi")
197+
198+
assert result.final_output == "answer is 42"

0 commit comments

Comments
 (0)