Skip to content

Commit b10e00e

Browse files
committed
fix(hooks): also preserve stdout in failed-hook results, pin contract
The previous fix (64a00e7e8) restored Stderr preservation in runHook's err and timeout paths but constructed a fresh HandlerResult with only Stderr + ExitCode — silently dropping any Stdout the handler had captured before failing. The original pre-refactor code preserved both, and a future caller in aggregate consuming r.Stdout on the err branch would silently see "" instead of the captured output. Replace the explicit field listing with a small markFailed closure that mutates the already-constructed r in place, exactly matching the old "set ExitCode = -1, clear Output, attach err" pattern. This: - preserves both Stdout and Stderr (matching pre-refactor behaviour), - is robust to future fields on HandlerResult — they propagate automatically rather than needing each call site updated, - makes the contract explicit in one named place that aggregate's err-branch reads stderr from. Add TestExecutorHandlerErrorPreservesStderr to pin the contract aggregate already relies on. The original regression slipped through because no existing test asserted result.Stderr for the PreToolUse fail-closed path; this test would have failed against the pre-64a00e7e8 code. Assisted-By: docker-agent
1 parent 3a8201e commit b10e00e

2 files changed

Lines changed: 56 additions & 8 deletions

File tree

‎pkg/hooks/executor.go‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,23 +211,31 @@ func (e *Executor) runHook(ctx context.Context, hook Hook, inputJSON []byte) hoo
211211
res, err := handler.Run(timeoutCtx, inputJSON)
212212
r := hookResult{HandlerResult: res}
213213

214+
// markFailed turns r into a "did not complete" outcome: the
215+
// handler's diagnostic stdout/stderr survive (aggregate surfaces
216+
// stderr in the PreToolUse fail-closed message), ExitCode is
217+
// pinned to -1 to match the documented [Result.ExitCode]
218+
// convention, any partial Output is dropped (it can't have been
219+
// authoritative if the run didn't complete), and rerr lands in
220+
// hookResult.err for the err-!= nil branch in [aggregate].
221+
markFailed := func(rerr error) hookResult {
222+
r.ExitCode = -1
223+
r.Output = nil
224+
r.err = rerr
225+
return r
226+
}
227+
214228
// Normalize timeout/cancellation: handler error types vary, so we
215229
// rewrite to a uniform error so PreToolUse fails closed cleanly.
216230
if ctxErr := timeoutCtx.Err(); ctxErr != nil {
217231
reason := "cancelled"
218232
if errors.Is(ctxErr, context.DeadlineExceeded) {
219233
reason = fmt.Sprintf("timed out after %s", hook.GetTimeout())
220234
}
221-
return hookResult{
222-
HandlerResult: HandlerResult{Stderr: res.Stderr, ExitCode: -1},
223-
err: fmt.Errorf("hook %q %s: %w", hook.Command, reason, ctxErr),
224-
}
235+
return markFailed(fmt.Errorf("hook %q %s: %w", hook.Command, reason, ctxErr))
225236
}
226237
if err != nil {
227-
return hookResult{
228-
HandlerResult: HandlerResult{Stderr: res.Stderr, ExitCode: -1},
229-
err: err,
230-
}
238+
return markFailed(err)
231239
}
232240

233241
// Fall back to the legacy "parse JSON from stdout" protocol.

‎pkg/hooks/handler_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,46 @@ func TestExecutorHandlerErrorDeniesPreToolUse(t *testing.T) {
160160
assert.Equal(t, -1, result.ExitCode)
161161
}
162162

163+
// TestExecutorHandlerErrorPreservesStderr pins the contract that when a
164+
// handler returns an error, the diagnostic stderr it captured before
165+
// failing survives all the way to [Result.Stderr]. aggregate routes
166+
// that field into the user-visible PreToolUse fail-closed message; if
167+
// runHook ever drops it on the floor (as it briefly did during the
168+
// HandlerResult-embedding refactor) PreToolUse denials would lose
169+
// their explanatory text.
170+
func TestExecutorHandlerErrorPreservesStderr(t *testing.T) {
171+
t.Parallel()
172+
173+
const customType HookType = "errors-with-stderr"
174+
const diagnostic = "BOOM: subprocess crashed at line 42"
175+
176+
registry := NewRegistry()
177+
registry.Register(customType, func(_ HandlerEnv, _ Hook) (Handler, error) {
178+
return handlerFunc(func(context.Context, []byte) (HandlerResult, error) {
179+
// Mirrors what commandHandler does on a spawn failure: it
180+
// captured stderr, then surfaces an exec-level error.
181+
return HandlerResult{Stderr: diagnostic, ExitCode: -1}, errors.New("spawn failed")
182+
}), nil
183+
})
184+
185+
config := &Config{
186+
PreToolUse: []MatcherConfig{
187+
{Matcher: "*", Hooks: []Hook{{Type: customType, Timeout: 5}}},
188+
},
189+
}
190+
191+
exec := NewExecutorWithRegistry(config, t.TempDir(), nil, registry)
192+
result, err := exec.Dispatch(t.Context(), EventPreToolUse, &Input{
193+
SessionID: "s", ToolName: "shell", ToolUseID: "t",
194+
})
195+
require.NoError(t, err)
196+
197+
assert.False(t, result.Allowed)
198+
assert.Equal(t, -1, result.ExitCode)
199+
assert.Equal(t, diagnostic, result.Stderr,
200+
"handler-captured stderr must survive the err != nil normalization in runHook")
201+
}
202+
163203
// handlerFunc adapts a function value into a [Handler] for terse tests.
164204
type handlerFunc func(context.Context, []byte) (HandlerResult, error)
165205

0 commit comments

Comments
 (0)