Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/runtime-hooks-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ P2 仅支持:
- `kind=builtin`
- `mode=sync`
- 挂载点:与 `HookPointCapability` 中 `UserAllowed=true` 的点位一致,当前包括:
`before_tool_call`、`after_tool_result`、`before_completion_decision`、`after_tool_failure`、
`before_tool_call`、`after_tool_result`、`before_completion_decision`、`accept_gate`、`after_tool_failure`、
`session_start`、`session_end`、`user_prompt_submit`、`post_compact`、`subagent_stop`
- handler:`require_file_exists`、`warn_on_tool_call`、`add_context_note`
- `kind=http + mode=observe`:允许发送 HTTP 观测回调(不支持 block)
Expand Down Expand Up @@ -91,6 +91,7 @@ runtime 内置 `HookPointCapability` 作为唯一真源,定义每个点位是
- `before_tool_call`
- `after_tool_result`
- `before_completion_decision`
- `accept_gate`
- `before_permission_decision`
- `after_tool_failure`
- `session_start`
Expand Down
46 changes: 5 additions & 41 deletions internal/config/runtime_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"net"
"net/url"
"strings"

"neo-code/internal/runtime/hooks"
)

const (
Expand Down Expand Up @@ -38,22 +40,6 @@ var runtimeHookExternalKinds = map[string]struct{}{
"agent": {},
}

const (
runtimeHookPointBeforeToolCall = "before_tool_call"
runtimeHookPointAfterToolResult = "after_tool_result"
runtimeHookPointBeforeCompletionDecision = "before_completion_decision"
runtimeHookPointAcceptGate = "accept_gate"
runtimeHookPointBeforePermissionDecision = "before_permission_decision"
runtimeHookPointAfterToolFailure = "after_tool_failure"
runtimeHookPointSessionStart = "session_start"
runtimeHookPointSessionEnd = "session_end"
runtimeHookPointUserPromptSubmit = "user_prompt_submit"
runtimeHookPointPreCompact = "pre_compact"
runtimeHookPointPostCompact = "post_compact"
runtimeHookPointSubAgentStart = "subagent_start"
runtimeHookPointSubAgentStop = "subagent_stop"
)

const (
runtimeHookHandlerRequireFileExists = "require_file_exists"
runtimeHookHandlerWarnOnToolCall = "warn_on_tool_call"
Expand Down Expand Up @@ -246,25 +232,11 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error {
if strings.TrimSpace(c.ID) == "" {
return fmt.Errorf("id is required")
}
point := strings.TrimSpace(c.Point)
switch point {
case runtimeHookPointBeforeToolCall,
runtimeHookPointAfterToolResult,
runtimeHookPointBeforeCompletionDecision,
runtimeHookPointAcceptGate,
runtimeHookPointBeforePermissionDecision,
runtimeHookPointAfterToolFailure,
runtimeHookPointSessionStart,
runtimeHookPointSessionEnd,
runtimeHookPointUserPromptSubmit,
runtimeHookPointPreCompact,
runtimeHookPointPostCompact,
runtimeHookPointSubAgentStart,
runtimeHookPointSubAgentStop:
default:
point := hooks.HookPoint(strings.TrimSpace(c.Point))
if _, ok := hooks.HookPointCapabilities(point); !ok {
return fmt.Errorf("point %q is not supported", c.Point)
}
if !runtimeHookPointUserAllowed(point) {
if !hooks.IsUserAllowed(point) {
return fmt.Errorf("point %q does not allow user hooks", c.Point)
}

Expand Down Expand Up @@ -472,11 +444,3 @@ func readRuntimeHookParamString(params map[string]any, key string) string {
}
}

func runtimeHookPointUserAllowed(point string) bool {
switch strings.ToLower(strings.TrimSpace(point)) {
case runtimeHookPointBeforePermissionDecision, runtimeHookPointPreCompact, runtimeHookPointSubAgentStart:
return false
default:
return true
}
}
110 changes: 84 additions & 26 deletions internal/config/runtime_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package config
import (
"strings"
"testing"

"neo-code/internal/runtime/hooks"
)

func TestRuntimeHooksConfigApplyDefaultsAndValidate(t *testing.T) {
Expand Down Expand Up @@ -46,31 +48,31 @@ func TestRuntimeHooksConfigValidateUnsupportedFields(t *testing.T) {
tests := []RuntimeHookItemConfig{
{
ID: "bad-scope",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: "repo",
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Handler: runtimeHookHandlerWarnOnToolCall,
},
{
ID: "bad-kind",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: "command",
Mode: runtimeHookModeSync,
Handler: runtimeHookHandlerWarnOnToolCall,
},
{
ID: "bad-mode",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: "async",
Handler: runtimeHookHandlerWarnOnToolCall,
},
{
ID: "bad-handler",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Expand Down Expand Up @@ -113,7 +115,7 @@ func TestRuntimeHooksConfigValidateRejectsExternalKindsWithP6LiteMessage(t *test
cfg.Items = []RuntimeHookItemConfig{
{
ID: "external-kind",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: kind,
Mode: runtimeHookModeSync,
Expand Down Expand Up @@ -144,7 +146,7 @@ func TestRuntimeHooksConfigValidateAllowsCommand(t *testing.T) {
Items: []RuntimeHookItemConfig{
{
ID: "accept-command",
Point: runtimeHookPointAcceptGate,
Point: string(hooks.HookPointAcceptGate),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindCommand,
Mode: runtimeHookModeSync,
Expand All @@ -170,7 +172,7 @@ func TestRuntimeHooksConfigValidateAllowsHTTPObserve(t *testing.T) {
Items: []RuntimeHookItemConfig{
{
ID: "observe-http",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindHTTP,
Params: map[string]any{
Expand All @@ -193,7 +195,7 @@ func TestRuntimeHooksConfigValidateRejectsInvalidHTTPObserveConfig(t *testing.T)

base := RuntimeHookItemConfig{
ID: "observe-http",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindHTTP,
Mode: runtimeHookModeObserve,
Expand Down Expand Up @@ -281,7 +283,7 @@ func TestRuntimeHooksConfigValidateRejectsDisallowedUserPoint(t *testing.T) {
Items: []RuntimeHookItemConfig{
{
ID: "deny-pre-compact",
Point: runtimeHookPointPreCompact,
Point: string(hooks.HookPointPreCompact),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Expand All @@ -308,7 +310,7 @@ func TestRuntimeHooksConfigItemDefaultsAndClone(t *testing.T) {
Items: []RuntimeHookItemConfig{
{
ID: "warn-bash",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Handler: runtimeHookHandlerWarnOnToolCall,
Params: map[string]any{
"tool_name": "bash",
Expand Down Expand Up @@ -368,7 +370,7 @@ func TestRuntimeHooksConfigValidateItemFailurePolicy(t *testing.T) {
Items: []RuntimeHookItemConfig{
{
ID: "require-readme",
Point: runtimeHookPointBeforeCompletionDecision,
Point: string(hooks.HookPointBeforeCompletionDecision),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Expand All @@ -394,7 +396,7 @@ func TestRuntimeHooksConfigValidateWarnOnToolCallRequiresTarget(t *testing.T) {
Items: []RuntimeHookItemConfig{
{
ID: "warn-missing-target",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Expand Down Expand Up @@ -449,8 +451,8 @@ func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
DefaultTimeoutSec: 2,
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
Items: []RuntimeHookItemConfig{
{ID: "dup", Point: runtimeHookPointBeforeToolCall, Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
{ID: " DUP ", Point: runtimeHookPointBeforeToolCall, Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
{ID: "dup", Point: string(hooks.HookPointBeforeToolCall), Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
{ID: " DUP ", Point: string(hooks.HookPointBeforeToolCall), Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
},
}
if err := cfg.Validate(); err == nil {
Expand All @@ -465,7 +467,7 @@ func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
}
item := RuntimeHookItemConfig{
ID: "x",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Expand Down Expand Up @@ -554,7 +556,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
} {
item := RuntimeHookItemConfig{
ID: "observe-http",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindHTTP,
Mode: runtimeHookModeObserve,
Expand All @@ -580,7 +582,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
name: "invalid absolute url",
item: RuntimeHookItemConfig{
ID: "observe-http",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindHTTP,
Mode: runtimeHookModeObserve,
Expand All @@ -593,7 +595,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
name: "headers must be map",
item: RuntimeHookItemConfig{
ID: "observe-http",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindHTTP,
Mode: runtimeHookModeObserve,
Expand All @@ -607,7 +609,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
name: "empty header name",
item: RuntimeHookItemConfig{
ID: "observe-http",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindHTTP,
Mode: runtimeHookModeObserve,
Expand All @@ -621,7 +623,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
name: "empty header value",
item: RuntimeHookItemConfig{
ID: "observe-http",
Point: runtimeHookPointBeforeToolCall,
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindHTTP,
Mode: runtimeHookModeObserve,
Expand Down Expand Up @@ -663,17 +665,73 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
if got := readRuntimeHookParamString(map[string]any{"x": 123}, "x"); got != "123" {
t.Fatalf("readRuntimeHookParamString(non-string) = %q", got)
}
if !runtimeHookPointUserAllowed(runtimeHookPointBeforeToolCall) {
if !hooks.IsUserAllowed(hooks.HookPointBeforeToolCall) {
t.Fatal("before_tool_call should allow user hooks")
}
for _, point := range []string{
runtimeHookPointBeforePermissionDecision,
runtimeHookPointPreCompact,
runtimeHookPointSubAgentStart,
for _, point := range []hooks.HookPoint{
hooks.HookPointBeforePermissionDecision,
hooks.HookPointPreCompact,
hooks.HookPointSubAgentStart,
} {
if runtimeHookPointUserAllowed(point) {
if hooks.IsUserAllowed(point) {
t.Fatalf("%s should be rejected for user hooks", point)
}
}
})
}

// TestHookPointSingleSourceConsistency 验证 config 侧与 runtime hooks 包的点位定义一致。
// 新增 hook point 时只需修改 runtime hooks 包,config 侧自动接受。
func TestHookPointSingleSourceConsistency(t *testing.T) {
t.Parallel()

// 所有 runtime hooks 包导出的点位都应被 config 接受。
allPoints := hooks.ListHookPoints()
if len(allPoints) == 0 {
t.Fatal("expected at least one hook point from runtime hooks package")
}

base := RuntimeHooksConfig{
Enabled: boolPtr(true),
UserHooksEnabled: boolPtr(true),
DefaultTimeoutSec: 2,
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
}

for _, point := range allPoints {
point := point
t.Run(string(point), func(t *testing.T) {
t.Parallel()
if !hooks.IsUserAllowed(point) {
// 跳过不允许 user 的点位,它们在 config 校验中会被拒绝。
return
}
cfg := base.Clone()
cfg.Items = []RuntimeHookItemConfig{
{
ID: "test-" + string(point),
Point: string(point),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Handler: runtimeHookHandlerAddContextNote,
TimeoutSec: 2,
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
Params: map[string]any{"note": "consistency check"},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("config rejected point %q: %v", point, err)
}
})
}

// 验证 accept_gate 在 runtime hooks 包中存在且允许 user。
acceptGateCap, ok := hooks.HookPointCapabilities(hooks.HookPointAcceptGate)
if !ok {
t.Fatal("accept_gate not found in runtime hooks capabilities")
}
if !acceptGateCap.UserAllowed {
t.Fatal("accept_gate should allow user hooks")
}
}
28 changes: 28 additions & 0 deletions internal/runtime/hooks/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package hooks

import (
"context"
"sort"
"strings"
"time"
)
Expand Down Expand Up @@ -224,3 +225,30 @@ func HookPointCapabilities(point HookPoint) (HookPointCapability, bool) {
capability, ok := hookPointCapabilities[point]
return capability, ok
}

// ListHookPoints 返回所有已注册的 hook 点位(按字符串排序,保证确定性)。
func ListHookPoints() []HookPoint {
Comment thread
Cai-Tang-www marked this conversation as resolved.
points := make([]HookPoint, 0, len(hookPointCapabilities))
for point := range hookPointCapabilities {
points = append(points, point)
}
sort.Slice(points, func(i, j int) bool {
return points[i] < points[j]
})
return points
}

// IsUserAllowed 返回指定点位是否允许 user scope hook 挂载。
func IsUserAllowed(point HookPoint) bool {
capability, ok := hookPointCapabilities[point]
if !ok {
return false
}
return capability.UserAllowed
}

// IsRepoAllowed 返回指定点位是否允许 repo scope hook 挂载。
// 当前 repo 与 user 共享相同的 allowed 策略。
func IsRepoAllowed(point HookPoint) bool {
Comment thread
Cai-Tang-www marked this conversation as resolved.
return IsUserAllowed(point)
}
Loading
Loading