Skip to content

Commit 5ac1cd9

Browse files
committed
feat(hooks): add on_tool_approval_decision event
Fires after the runtime's tool approval chain (yolo / permissions / readonly / ask) resolves a verdict for a tool call, BEFORE the call is executed (allow) or its error response is recorded (deny / canceled). Until now this verdict was implicit \u2014 reconstructible only by correlating ToolCall, ToolCallConfirmation, ToolCallResponse, and HookBlocked events from the runtime channel. The hook gives audit pipelines a single, structured "who approved what" record. Two new typed Input fields: - ApprovalDecision: "allow" | "deny" | "canceled" - ApprovalSource: stable classifier for which step decided (yolo, session_permissions_allow, session_permissions_deny, team_permissions_allow, team_permissions_deny, readonly_hint, user_approved, user_approved_session, user_approved_tool, user_rejected, context_canceled) Constants live on the runtime side as Approval{Decision,Source}* so the contract between executeWithApproval and the hook protocol is discoverable from one place. allowSourceFor / denySourceFor map the existing permissionChecker.source labels onto the public classifiers; unknown labels default to team_permissions to avoid silent misclassification on future label changes. The hook is fired at every return path of executeWithApproval and askUserForConfirmation, so a single hook gets exactly one record per tool call regardless of which step decided. Existing event consumers see no change. Assisted-By: docker-agent
1 parent 86bc6f0 commit 5ac1cd9

7 files changed

Lines changed: 214 additions & 15 deletions

File tree

agent-schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,13 @@
581581
"items": {
582582
"$ref": "#/definitions/HookDefinition"
583583
}
584+
},
585+
"on_tool_approval_decision": {
586+
"type": "array",
587+
"description": "Hooks that run after the runtime's tool approval chain (yolo / permissions / readonly / ask) resolves a verdict for a tool call, before the call is executed (allow) or its error response is recorded (deny / canceled). Receives approval_decision (\"allow\" | \"deny\" | \"canceled\") and approval_source (a stable classifier of which step decided). Observational; gives audit pipelines a single \"who approved what\" record without re-implementing the chain.",
588+
"items": {
589+
"$ref": "#/definitions/HookDefinition"
590+
}
584591
}
585592
},
586593
"additionalProperties": false

pkg/config/latest/types.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,12 @@ type HooksConfig struct {
16811681
// runtime to continue past its configured max_iterations limit.
16821682
// Observational; useful for alerting on extended-runtime sessions.
16831683
OnSessionResume []HookDefinition `json:"on_session_resume,omitempty" yaml:"on_session_resume,omitempty"`
1684+
1685+
// OnToolApprovalDecision hooks run after the runtime's tool
1686+
// approval chain resolves a verdict for a tool call. Observational;
1687+
// gives audit pipelines a structured "who approved what" record
1688+
// without re-implementing the chain.
1689+
OnToolApprovalDecision []HookDefinition `json:"on_tool_approval_decision,omitempty" yaml:"on_tool_approval_decision,omitempty"`
16841690
}
16851691

16861692
// IsEmpty returns true if no hooks are configured
@@ -1701,7 +1707,8 @@ func (h *HooksConfig) IsEmpty() bool {
17011707
len(h.OnError) == 0 &&
17021708
len(h.OnMaxIterations) == 0 &&
17031709
len(h.OnAgentSwitch) == 0 &&
1704-
len(h.OnSessionResume) == 0
1710+
len(h.OnSessionResume) == 0 &&
1711+
len(h.OnToolApprovalDecision) == 0
17051712
}
17061713

17071714
// HookMatcherConfig represents a hook matcher with its hooks.
@@ -1849,6 +1856,13 @@ func (h *HooksConfig) validate() error {
18491856
}
18501857
}
18511858

1859+
// Validate OnToolApprovalDecision hooks
1860+
for i, hook := range h.OnToolApprovalDecision {
1861+
if err := hook.validate("on_tool_approval_decision", i); err != nil {
1862+
return err
1863+
}
1864+
}
1865+
18521866
return nil
18531867
}
18541868

pkg/hooks/executor.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,21 @@ func compileEvents(c *Config) map[EventType][]matcher {
7373
return []matcher{{hooks: hooks}}
7474
}
7575
return map[EventType][]matcher{
76-
EventPreToolUse: compileMatchers(c.PreToolUse),
77-
EventPostToolUse: compileMatchers(c.PostToolUse),
78-
EventSessionStart: flat(c.SessionStart),
79-
EventTurnStart: flat(c.TurnStart),
80-
EventBeforeLLMCall: flat(c.BeforeLLMCall),
81-
EventAfterLLMCall: flat(c.AfterLLMCall),
82-
EventSessionEnd: flat(c.SessionEnd),
83-
EventOnUserInput: flat(c.OnUserInput),
84-
EventStop: flat(c.Stop),
85-
EventNotification: flat(c.Notification),
86-
EventOnError: flat(c.OnError),
87-
EventOnMaxIterations: flat(c.OnMaxIterations),
88-
EventOnAgentSwitch: flat(c.OnAgentSwitch),
89-
EventOnSessionResume: flat(c.OnSessionResume),
76+
EventPreToolUse: compileMatchers(c.PreToolUse),
77+
EventPostToolUse: compileMatchers(c.PostToolUse),
78+
EventSessionStart: flat(c.SessionStart),
79+
EventTurnStart: flat(c.TurnStart),
80+
EventBeforeLLMCall: flat(c.BeforeLLMCall),
81+
EventAfterLLMCall: flat(c.AfterLLMCall),
82+
EventSessionEnd: flat(c.SessionEnd),
83+
EventOnUserInput: flat(c.OnUserInput),
84+
EventStop: flat(c.Stop),
85+
EventNotification: flat(c.Notification),
86+
EventOnError: flat(c.OnError),
87+
EventOnMaxIterations: flat(c.OnMaxIterations),
88+
EventOnAgentSwitch: flat(c.OnAgentSwitch),
89+
EventOnSessionResume: flat(c.OnSessionResume),
90+
EventOnToolApprovalDecision: flat(c.OnToolApprovalDecision),
9091
}
9192
}
9293

pkg/hooks/types.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ const (
5858
// Observational; useful for alerting on extended-runtime sessions
5959
// or for pipelines that bill / quota-track per resume.
6060
EventOnSessionResume EventType = "on_session_resume"
61+
// EventOnToolApprovalDecision fires after the runtime's tool
62+
// approval chain (yolo / permissions / readonly / ask) has resolved
63+
// a verdict for a tool call, before the call is executed (for
64+
// allow) or its error response is recorded (for deny / canceled).
65+
// Observational; gives audit pipelines a single, structured "who
66+
// approved what" record without re-implementing the chain.
67+
EventOnToolApprovalDecision EventType = "on_tool_approval_decision"
6168
)
6269

6370
// consumesContext reports whether the runtime emit site for e routes
@@ -113,6 +120,16 @@ type Input struct {
113120
// reconstructing it from the iteration counter.
114121
PreviousMaxIterations int `json:"previous_max_iterations,omitempty"`
115122
NewMaxIterations int `json:"new_max_iterations,omitempty"`
123+
124+
// OnToolApprovalDecision specific: the verdict resolved by the
125+
// approval chain ("allow", "deny", "canceled") and a stable
126+
// classifier for what produced it ("yolo",
127+
// "session_permissions_allow", "session_permissions_deny",
128+
// "team_permissions_allow", "team_permissions_deny",
129+
// "readonly_hint", "user_approved", "user_approved_session",
130+
// "user_approved_tool", "user_rejected", "context_canceled").
131+
ApprovalDecision string `json:"approval_decision,omitempty"`
132+
ApprovalSource string `json:"approval_source,omitempty"`
116133
}
117134

118135
// ToJSON serializes the input.

pkg/runtime/hooks.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/docker/docker-agent/pkg/hooks"
1010
"github.com/docker/docker-agent/pkg/hooks/builtins"
1111
"github.com/docker/docker-agent/pkg/session"
12+
"github.com/docker/docker-agent/pkg/tools"
1213
)
1314

1415
// buildHooksExecutors builds a [hooks.Executor] for every agent in the
@@ -215,6 +216,46 @@ func (r *LocalRuntime) executeOnSessionResumeHooks(ctx context.Context, a *agent
215216
}, nil)
216217
}
217218

219+
// Verdicts and sources for [hooks.EventOnToolApprovalDecision]. Constants
220+
// instead of literals so the contract between executeWithApproval and
221+
// the hook protocol is discoverable from the runtime side and a typo
222+
// trips a compile error.
223+
const (
224+
ApprovalDecisionAllow = "allow"
225+
ApprovalDecisionDeny = "deny"
226+
ApprovalDecisionCanceled = "canceled"
227+
228+
ApprovalSourceYolo = "yolo"
229+
ApprovalSourceSessionPermissionsAllow = "session_permissions_allow"
230+
ApprovalSourceSessionPermissionsDeny = "session_permissions_deny"
231+
ApprovalSourceTeamPermissionsAllow = "team_permissions_allow"
232+
ApprovalSourceTeamPermissionsDeny = "team_permissions_deny"
233+
ApprovalSourceReadOnlyHint = "readonly_hint"
234+
ApprovalSourceUserApproved = "user_approved"
235+
ApprovalSourceUserApprovedSession = "user_approved_session"
236+
ApprovalSourceUserApprovedTool = "user_approved_tool"
237+
ApprovalSourceUserRejected = "user_rejected"
238+
ApprovalSourceContextCanceled = "context_canceled"
239+
)
240+
241+
// executeOnToolApprovalDecisionHooks fires on_tool_approval_decision
242+
// after the runtime's approval chain has resolved a verdict for a
243+
// tool call. Fired once per call from each return path of
244+
// [executeWithApproval], so a single hook gets one record per tool
245+
// call regardless of which step decided.
246+
func (r *LocalRuntime) executeOnToolApprovalDecisionHooks(
247+
ctx context.Context,
248+
sess *session.Session,
249+
a *agent.Agent,
250+
toolCall tools.ToolCall,
251+
decision, source string,
252+
) {
253+
input := newHooksInput(sess, toolCall)
254+
input.ApprovalDecision = decision
255+
input.ApprovalSource = source
256+
r.dispatchHook(ctx, a, hooks.EventOnToolApprovalDecision, input, nil)
257+
}
258+
218259
// executeBeforeLLMCallHooks fires before_llm_call just before each
219260
// model call. A terminating verdict (decision="block" / continue=false
220261
// / exit 2) stops the run loop — see [hooks.EventBeforeLLMCall] for
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package runtime
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/agent"
10+
"github.com/docker/docker-agent/pkg/hooks"
11+
"github.com/docker/docker-agent/pkg/session"
12+
"github.com/docker/docker-agent/pkg/team"
13+
"github.com/docker/docker-agent/pkg/tools"
14+
)
15+
16+
// runtimeWithRecordedToolApproval mirrors the runtimeWithRecorded*
17+
// helpers for the on_tool_approval_decision event. Same pattern: a
18+
// recording builtin on the runtime's private registry so the test
19+
// can assert on the dispatched verdict + source without exposing a
20+
// production Opt that would tempt users to inject builtins ad hoc.
21+
func runtimeWithRecordedToolApproval(t *testing.T) (*LocalRuntime, *recordingBuiltin) {
22+
t.Helper()
23+
24+
rb := &recordingBuiltin{}
25+
prov := &mockProvider{id: "test/mock-model", stream: &mockStream{}}
26+
a := agent.New("root", "instructions",
27+
agent.WithModel(prov),
28+
agent.WithHooks(&hooks.Config{
29+
OnToolApprovalDecision: []hooks.Hook{{
30+
Type: hooks.HookTypeBuiltin,
31+
Command: "test_record_tool_approval",
32+
}},
33+
}),
34+
)
35+
tm := team.New(team.WithAgents(a))
36+
37+
r, err := NewLocalRuntime(tm, WithModelStore(mockModelStore{}))
38+
require.NoError(t, err)
39+
40+
require.NoError(t, r.hooksRegistry.RegisterBuiltin("test_record_tool_approval", rb.hook))
41+
r.buildHooksExecutors()
42+
43+
return r, rb
44+
}
45+
46+
// TestExecuteOnToolApprovalDecisionHooks_ForwardsVerdictAndSource pins
47+
// the contract: the dispatched Input carries the verdict and source
48+
// classifier verbatim, plus the tool-call identifying fields the
49+
// existing PreToolUse / PostToolUse hooks already use. That gives
50+
// audit pipelines a uniform "tool call X resulted in verdict Y from
51+
// source Z" record across the whole approval chain.
52+
func TestExecuteOnToolApprovalDecisionHooks_ForwardsVerdictAndSource(t *testing.T) {
53+
t.Parallel()
54+
55+
r, rb := runtimeWithRecordedToolApproval(t)
56+
a := r.CurrentAgent()
57+
require.NotNil(t, a)
58+
59+
sess := &session.Session{ID: "session-z"}
60+
tc := tools.ToolCall{
61+
ID: "call-1",
62+
Function: tools.FunctionCall{
63+
Name: "read_file",
64+
Arguments: `{"path":"/tmp/x"}`,
65+
},
66+
}
67+
r.executeOnToolApprovalDecisionHooks(t.Context(), sess, a, tc, ApprovalDecisionAllow, ApprovalSourceReadOnlyHint)
68+
69+
got := rb.snapshot()
70+
require.Len(t, got, 1)
71+
in := got[0]
72+
assert.Equal(t, "read_file", in.ToolName)
73+
assert.Equal(t, "call-1", in.ToolUseID)
74+
assert.Equal(t, ApprovalDecisionAllow, in.ApprovalDecision)
75+
assert.Equal(t, ApprovalSourceReadOnlyHint, in.ApprovalSource)
76+
}
77+
78+
// TestApprovalSourceMappersAreStable pins the stable classifier
79+
// strings used by [allowSourceFor] and [denySourceFor]. Tests that
80+
// the team-permissions vs session-permissions split (today: by
81+
// checker.source string match) survives changes to the inner labels.
82+
func TestApprovalSourceMappersAreStable(t *testing.T) {
83+
t.Parallel()
84+
85+
assert.Equal(t, ApprovalSourceSessionPermissionsAllow, allowSourceFor("session permissions"))
86+
assert.Equal(t, ApprovalSourceTeamPermissionsAllow, allowSourceFor("permissions configuration"))
87+
assert.Equal(t, ApprovalSourceTeamPermissionsAllow, allowSourceFor("anything-else"),
88+
"unknown source must default to team_permissions to avoid silent misclassification on future label changes")
89+
90+
assert.Equal(t, ApprovalSourceSessionPermissionsDeny, denySourceFor("session permissions"))
91+
assert.Equal(t, ApprovalSourceTeamPermissionsDeny, denySourceFor("permissions configuration"))
92+
}

pkg/runtime/tool_dispatch.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ func (r *LocalRuntime) executeWithApproval(
162162

163163
if sess.ToolsApproved {
164164
slog.Debug("Tool auto-approved by --yolo flag", "tool", toolName, "session_id", sess.ID)
165+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionAllow, ApprovalSourceYolo)
165166
return invoke()
166167
}
167168

@@ -178,11 +179,13 @@ func (r *LocalRuntime) executeWithApproval(
178179
switch pc.checker.CheckWithArgs(toolName, toolArgs) {
179180
case permissions.Deny:
180181
slog.Debug("Tool denied by permissions", "tool", toolName, "source", pc.source, "session_id", sess.ID)
182+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionDeny, denySourceFor(pc.source))
181183
r.addToolErrorResponse(ctx, sess, toolCall, tool, events, a,
182184
fmt.Sprintf("Tool '%s' is denied by %s.", toolName, pc.source))
183185
return toolApprovalOutcome{}
184186
case permissions.Allow:
185187
slog.Debug("Tool auto-approved by permissions", "tool", toolName, "source", pc.source, "session_id", sess.ID)
188+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionAllow, allowSourceFor(pc.source))
186189
return invoke()
187190
case permissions.ForceAsk:
188191
slog.Debug("Tool requires confirmation (ask pattern)", "tool", toolName, "source", pc.source, "session_id", sess.ID)
@@ -193,11 +196,30 @@ func (r *LocalRuntime) executeWithApproval(
193196
}
194197

195198
if tool.Annotations.ReadOnlyHint {
199+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionAllow, ApprovalSourceReadOnlyHint)
196200
return invoke()
197201
}
198202
return r.askUserForConfirmation(ctx, sess, toolCall, tool, events, a, invoke)
199203
}
200204

205+
// allowSourceFor maps a permission-checker source label to the
206+
// corresponding approval-decision source classifier. Centralised so
207+
// the strings stay aligned with [permissionChecker.source].
208+
func allowSourceFor(checkerSource string) string {
209+
if checkerSource == "session permissions" {
210+
return ApprovalSourceSessionPermissionsAllow
211+
}
212+
return ApprovalSourceTeamPermissionsAllow
213+
}
214+
215+
// denySourceFor mirrors allowSourceFor for the deny path.
216+
func denySourceFor(checkerSource string) string {
217+
if checkerSource == "session permissions" {
218+
return ApprovalSourceSessionPermissionsDeny
219+
}
220+
return ApprovalSourceTeamPermissionsDeny
221+
}
222+
201223
// permissionChecker pairs a checker with a human-readable source label.
202224
type permissionChecker struct {
203225
checker *permissions.Checker
@@ -249,10 +271,12 @@ func (r *LocalRuntime) askUserForConfirmation(
249271
switch req.Type {
250272
case ResumeTypeApprove:
251273
slog.Debug("Resume signal received, approving tool", "tool", toolName, "session_id", sess.ID)
274+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionAllow, ApprovalSourceUserApproved)
252275
return invoke()
253276
case ResumeTypeApproveSession:
254277
slog.Debug("Resume signal received, approving session", "tool", toolName, "session_id", sess.ID)
255278
sess.ToolsApproved = true
279+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionAllow, ApprovalSourceUserApprovedSession)
256280
return invoke()
257281
case ResumeTypeApproveTool:
258282
approvedTool := req.ToolName
@@ -266,9 +290,11 @@ func (r *LocalRuntime) askUserForConfirmation(
266290
sess.Permissions.Allow = append(sess.Permissions.Allow, approvedTool)
267291
}
268292
slog.Debug("Resume signal received, approving tool permanently", "tool", approvedTool, "session_id", sess.ID)
293+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionAllow, ApprovalSourceUserApprovedTool)
269294
return invoke()
270295
case ResumeTypeReject:
271296
slog.Debug("Resume signal received, rejecting tool", "tool", toolName, "session_id", sess.ID, "reason", req.Reason)
297+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionDeny, ApprovalSourceUserRejected)
272298
rejectMsg := "The user rejected the tool call."
273299
if strings.TrimSpace(req.Reason) != "" {
274300
rejectMsg += " Reason: " + strings.TrimSpace(req.Reason)
@@ -278,6 +304,7 @@ func (r *LocalRuntime) askUserForConfirmation(
278304
return toolApprovalOutcome{}
279305
case <-ctx.Done():
280306
slog.Debug("Context cancelled while waiting for resume", "tool", toolName, "session_id", sess.ID)
307+
r.executeOnToolApprovalDecisionHooks(ctx, sess, a, toolCall, ApprovalDecisionCanceled, ApprovalSourceContextCanceled)
281308
r.addToolErrorResponse(ctx, sess, toolCall, tool, events, a, "The tool call was canceled by the user.")
282309
return toolApprovalOutcome{canceled: true}
283310
}

0 commit comments

Comments
 (0)