Skip to content

Commit 5bb2b80

Browse files
authored
Merge pull request #704 from Cai-Tang-www/feat/hook-matcher-684
feat(hooks): unified Hook Matcher DSL for hook filtering
2 parents 560c6fe + 90df2ab commit 5bb2b80

18 files changed

Lines changed: 1250 additions & 190 deletions

docs/examples/hooks.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ hooks:
1717
kind: builtin
1818
mode: sync
1919
handler: warn_on_tool_call
20+
match:
21+
tool_name: ["bash"]
2022
params:
21-
tool_names: ["bash"]
2223
message: "执行 bash 前请确认命令不会破坏工作区。"
2324

2425
- id: require-readme-before-final

docs/examples/user-hooks-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ runtime:
2525
kind: builtin
2626
mode: sync
2727
handler: warn_on_tool_call
28+
match:
29+
tool_name: ["bash"]
2830
params:
29-
tool_names: ["bash"]
3031
message: "执行 bash 前请确认命令不会破坏工作区。"
3132

3233
- id: user-http-observe

docs/runtime-hooks-design.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ P2 仅支持:
3030
`before_tool_call``after_tool_result``before_completion_decision``accept_gate``after_tool_failure`
3131
`session_start``session_end``user_prompt_submit``post_compact``subagent_stop`
3232
- handler:`require_file_exists``warn_on_tool_call``add_context_note`
33+
- `match`:统一 matcher DSL(字段间 AND、同字段多值 OR),支持:
34+
- `tool_name`:精确匹配(`string``[]string`
35+
- `tool_name_regex`:正则匹配(`string``[]string`,单条最长 256)
36+
- `arguments_contains`:参数预览包含匹配(`[]string`
3337
- `kind=http + mode=observe`:允许发送 HTTP 观测回调(不支持 block)
3438
- `http observe` 默认不携带 metadata(`include_metadata=false`);即使显式开启也会剥离 `result_content_preview``execution_error`
3539
- `http observe` 回调端点仅允许 loopback 地址(`localhost` / `127.0.0.1` / `::1`),避免误配为公网外发
@@ -73,6 +77,7 @@ user/repo hook 接收的 `HookContext` 经过白名单裁剪,仅保留最小
7377

7478
- `run_id` / `session_id`
7579
- `point` / `tool_call_id` / `tool_name`
80+
- `tool_arguments_preview`(脱敏+截断后的参数预览)
7681
- `is_error` / `error_class`
7782
- `result_content_preview` / `result_metadata_present`
7883
- `execution_error`
@@ -109,6 +114,22 @@ runtime 内置 `HookPointCapability` 作为唯一真源,定义每个点位是
109114
- `CanBlock=false` 的点位,hook 返回 `block` 会自动降级为观测结果,不中断主链。
110115
- `CanUpdateInput``user_prompt_submit` 点位已开放:command hook 可通过 stdout JSON 的 `update_input` 字段改写用户输入。
111116
- `UserAllowed=false` 的点位拒绝 user/repo 挂载(配置 fail-fast)。
117+
- matcher 字段会按点位能力矩阵做 fail-fast:不支持的维度会在配置加载阶段直接报错。
118+
119+
### matcher 点位维度矩阵(#684
120+
121+
| point | tool_name | tool_name_regex | arguments_contains |
122+
|---|---|---|---|
123+
| `before_tool_call` ||||
124+
| `after_tool_result` ||||
125+
| `after_tool_failure` ||||
126+
| `before_permission_decision` ||||
127+
| 其他点位 ||||
128+
129+
说明:
130+
131+
- `arguments_contains` 基于 `tool_arguments_preview` 字段匹配,不读取 `tool_arguments` 原文。
132+
- `warn_on_tool_call` 当前要求显式配置 `match`;旧参数 `params.tool_name/tool_names` 不再承担匹配语义。
112133

113134
### trust gate
114135

internal/config/loader_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ runtime:
126126
priority: 100
127127
timeout_sec: 2
128128
failure_policy: warn_only
129-
params:
129+
match:
130130
tool_name: bash
131+
params:
131132
message: "bash is called"
132133
`
133134
writeLoaderConfig(t, loader, raw)

internal/config/runtime_hooks.go

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ type RuntimeHookItemConfig struct {
6464
Kind string `yaml:"kind,omitempty"`
6565
Mode string `yaml:"mode,omitempty"`
6666
Handler string `yaml:"handler,omitempty"`
67+
Match map[string]any `yaml:"match,omitempty"`
6768
Priority int `yaml:"priority,omitempty"`
6869
TimeoutSec int `yaml:"timeout_sec,omitempty"`
6970
FailurePolicy string `yaml:"failure_policy,omitempty"`
@@ -189,6 +190,12 @@ func (c RuntimeHookItemConfig) Clone() RuntimeHookItemConfig {
189190
if c.Enabled != nil {
190191
cloned.Enabled = boolPtr(*c.Enabled)
191192
}
193+
if len(c.Match) > 0 {
194+
cloned.Match = make(map[string]any, len(c.Match))
195+
for key, value := range c.Match {
196+
cloned.Match[key] = cloneRuntimeHookParamValue(value)
197+
}
198+
}
192199
if len(c.Params) > 0 {
193200
cloned.Params = make(map[string]any, len(c.Params))
194201
for key, value := range c.Params {
@@ -279,23 +286,38 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error {
279286
default:
280287
return fmt.Errorf("handler %q is not supported", c.Handler)
281288
}
282-
if handler == runtimeHookHandlerWarnOnToolCall && !hasWarnOnToolCallTargets(c.Params) {
283-
return fmt.Errorf("handler %q requires params.tool_name or params.tool_names", c.Handler)
284-
}
289+
if handler == runtimeHookHandlerWarnOnToolCall && !hooks.HasHookMatcherConfig(c.Match) {
290+
return fmt.Errorf("handler %q requires match", c.Handler)
291+
}
292+
if hooks.HasHookMatcherConfig(c.Match) {
293+
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
294+
return fmt.Errorf("match: %w", err)
295+
}
296+
}
285297
case runtimeHookKindCommand:
286298
if normalizedMode != runtimeHookModeSync {
287299
return fmt.Errorf("mode %q is not supported for kind command (only sync)", c.Mode)
288300
}
289301
if err := hooks.ValidateCommandParams(c.Params); err != nil {
290302
return err
291303
}
304+
if hooks.HasHookMatcherConfig(c.Match) {
305+
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
306+
return fmt.Errorf("match: %w", err)
307+
}
308+
}
292309
case runtimeHookKindHTTP:
293310
if normalizedMode != runtimeHookModeObserve {
294311
return fmt.Errorf("mode %q is not supported for kind http (only observe)", c.Mode)
295312
}
296313
if err := validateRuntimeHTTPObserveItem(c, policy); err != nil {
297314
return err
298315
}
316+
if hooks.HasHookMatcherConfig(c.Match) {
317+
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
318+
return fmt.Errorf("match: %w", err)
319+
}
320+
}
299321
}
300322
return nil
301323
}
@@ -398,35 +420,6 @@ func cloneRuntimeHookParamValue(value any) any {
398420
}
399421
}
400422

401-
func hasWarnOnToolCallTargets(params map[string]any) bool {
402-
if len(params) == 0 {
403-
return false
404-
}
405-
toolNameRaw, hasToolName := params["tool_name"]
406-
if hasToolName && strings.TrimSpace(fmt.Sprintf("%v", toolNameRaw)) != "" {
407-
return true
408-
}
409-
toolNamesRaw, hasToolNames := params["tool_names"]
410-
if !hasToolNames || toolNamesRaw == nil {
411-
return false
412-
}
413-
switch typed := toolNamesRaw.(type) {
414-
case []string:
415-
for _, item := range typed {
416-
if strings.TrimSpace(item) != "" {
417-
return true
418-
}
419-
}
420-
case []any:
421-
for _, item := range typed {
422-
if strings.TrimSpace(fmt.Sprintf("%v", item)) != "" {
423-
return true
424-
}
425-
}
426-
}
427-
return false
428-
}
429-
430423
// readRuntimeHookParamString 以兼容方式读取 runtime hook 参数中的字符串值。
431424
func readRuntimeHookParamString(params map[string]any, key string) string {
432425
if len(params) == 0 {
@@ -443,4 +436,3 @@ func readRuntimeHookParamString(params map[string]any, key string) string {
443436
return fmt.Sprintf("%v", typed)
444437
}
445438
}
446-

internal/config/runtime_hooks_test.go

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -390,13 +390,15 @@ func TestRuntimeHooksConfigItemDefaultsAndClone(t *testing.T) {
390390
ID: "warn-bash",
391391
Point: string(hooks.HookPointBeforeToolCall),
392392
Handler: runtimeHookHandlerWarnOnToolCall,
393-
Params: map[string]any{
393+
Match: map[string]any{
394394
"tool_name": "bash",
395-
"tags": []any{"warn", "tool"},
395+
},
396+
Params: map[string]any{
397+
"tags": []any{"warn", "tool"},
396398
},
397399
},
398400
},
399-
}
401+
}
400402
cfg.ApplyDefaults(defaultRuntimeHooksConfig())
401403

402404
item := cfg.Items[0]
@@ -489,6 +491,65 @@ func TestRuntimeHooksConfigValidateWarnOnToolCallRequiresTarget(t *testing.T) {
489491
}
490492
}
491493

494+
func TestRuntimeHooksConfigValidateWarnOnToolCallAllowsMatchWithoutLegacyTargets(t *testing.T) {
495+
t.Parallel()
496+
497+
cfg := RuntimeHooksConfig{
498+
Enabled: boolPtr(true),
499+
UserHooksEnabled: boolPtr(true),
500+
DefaultTimeoutSec: 2,
501+
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
502+
Items: []RuntimeHookItemConfig{
503+
{
504+
ID: "warn-with-match",
505+
Point: string(hooks.HookPointBeforeToolCall),
506+
Scope: runtimeHookScopeUser,
507+
Kind: runtimeHookKindBuiltIn,
508+
Mode: runtimeHookModeSync,
509+
Handler: runtimeHookHandlerWarnOnToolCall,
510+
TimeoutSec: 2,
511+
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
512+
Match: map[string]any{
513+
"tool_name": "bash",
514+
},
515+
},
516+
},
517+
}
518+
if err := cfg.Validate(); err != nil {
519+
t.Fatalf("Validate() error = %v", err)
520+
}
521+
}
522+
523+
func TestRuntimeHooksConfigValidateRejectsUnsupportedMatcherDimensionForPoint(t *testing.T) {
524+
t.Parallel()
525+
526+
cfg := RuntimeHooksConfig{
527+
Enabled: boolPtr(true),
528+
UserHooksEnabled: boolPtr(true),
529+
DefaultTimeoutSec: 2,
530+
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
531+
Items: []RuntimeHookItemConfig{
532+
{
533+
ID: "session-start-match",
534+
Point: string(hooks.HookPointSessionStart),
535+
Scope: runtimeHookScopeUser,
536+
Kind: runtimeHookKindBuiltIn,
537+
Mode: runtimeHookModeSync,
538+
Handler: runtimeHookHandlerAddContextNote,
539+
TimeoutSec: 2,
540+
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
541+
Params: map[string]any{"note": "observe"},
542+
Match: map[string]any{
543+
"tool_name": "bash",
544+
},
545+
},
546+
},
547+
}
548+
if err := cfg.Validate(); err == nil {
549+
t.Fatal("expected unsupported matcher dimension to fail validation")
550+
}
551+
}
552+
492553
func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
493554
t.Parallel()
494555

@@ -605,20 +666,19 @@ func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
605666
t.Fatal("expected deep clone for nested map in slice")
606667
}
607668

608-
if hasWarnOnToolCallTargets(nil) {
609-
t.Fatal("nil params should be false")
610-
}
611-
if !hasWarnOnToolCallTargets(map[string]any{"tool_name": "bash"}) {
612-
t.Fatal("tool_name should pass")
613-
}
614-
if !hasWarnOnToolCallTargets(map[string]any{"tool_names": []string{"", "bash"}}) {
615-
t.Fatal("tool_names []string should pass")
616-
}
617-
if !hasWarnOnToolCallTargets(map[string]any{"tool_names": []any{"", "bash"}}) {
618-
t.Fatal("tool_names []any should pass")
669+
670+
matchCfg := RuntimeHookItemConfig{
671+
Match: map[string]any{
672+
"tool_name_regex": []any{`^bash$`},
673+
},
619674
}
620-
if hasWarnOnToolCallTargets(map[string]any{"tool_names": "bash"}) {
621-
t.Fatal("tool_names scalar should fail")
675+
clonedCfg := matchCfg.Clone()
676+
clonedRegexes := clonedCfg.Match["tool_name_regex"].([]any)
677+
clonedRegexes[0] = "^filesystem$"
678+
clonedCfg.Match["tool_name_regex"] = clonedRegexes
679+
originalRegexes := matchCfg.Match["tool_name_regex"].([]any)
680+
if originalRegexes[0] == "^filesystem$" {
681+
t.Fatal("expected match field to be deep-cloned")
622682
}
623683
})
624684
}

internal/runtime/hooks/executor.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ func (e *Executor) Run(ctx context.Context, point HookPoint, input HookContext)
7979
if spec.Scope == HookScopeUser || spec.Scope == HookScopeRepo {
8080
hookInput = sanitizeUserHookContext(hookInput)
8181
}
82+
if spec.Matcher != nil && !spec.Matcher.Match(hookInput) {
83+
continue
84+
}
8285
if spec.Mode == HookModeAsync || spec.Mode == HookModeAsyncRewake {
8386
e.runAsync(ctx, spec, hookInput)
8487
continue
@@ -340,6 +343,7 @@ func sanitizeUserHookContext(input HookContext) HookContext {
340343
"point": {},
341344
"tool_call_id": {},
342345
"tool_name": {},
346+
"tool_arguments_preview": {},
343347
"is_error": {},
344348
"error_class": {},
345349
"result_content_preview": {},

internal/runtime/hooks/executor_test.go

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -955,10 +955,11 @@ func TestExecutorSanitizeUserHookContext(t *testing.T) {
955955
RunID: "run-1",
956956
SessionID: "session-1",
957957
Metadata: map[string]any{
958-
"tool_name": "bash",
959-
"tool_arguments": "--secret-token=abc",
960-
"capability_token": "should-not-leak",
961-
"workdir": "/tmp/work",
958+
"tool_name": "bash",
959+
"tool_arguments": "--secret-token=abc",
960+
"tool_arguments_preview": "token=***",
961+
"capability_token": "should-not-leak",
962+
"workdir": "/tmp/work",
962963
},
963964
})
964965

@@ -971,6 +972,9 @@ func TestExecutorSanitizeUserHookContext(t *testing.T) {
971972
if _, exists := captured.Metadata["tool_arguments"]; exists {
972973
t.Fatal("tool_arguments should be stripped for user hook context")
973974
}
975+
if got := captured.Metadata["tool_arguments_preview"]; got != "token=***" {
976+
t.Fatalf("tool_arguments_preview = %v, want token=***", got)
977+
}
974978
if _, exists := captured.Metadata["capability_token"]; exists {
975979
t.Fatal("capability_token should be stripped for user hook context")
976980
}
@@ -999,10 +1003,11 @@ func TestExecutorSanitizeRepoHookContext(t *testing.T) {
9991003
RunID: "run-1",
10001004
SessionID: "session-1",
10011005
Metadata: map[string]any{
1002-
"tool_name": "bash",
1003-
"tool_arguments": "--secret-token=abc",
1004-
"capability_token": "should-not-leak",
1005-
"workdir": "/tmp/work",
1006+
"tool_name": "bash",
1007+
"tool_arguments": "--secret-token=abc",
1008+
"tool_arguments_preview": "token=***",
1009+
"capability_token": "should-not-leak",
1010+
"workdir": "/tmp/work",
10061011
},
10071012
})
10081013

@@ -1012,7 +1017,39 @@ func TestExecutorSanitizeRepoHookContext(t *testing.T) {
10121017
if _, exists := captured.Metadata["tool_arguments"]; exists {
10131018
t.Fatal("tool_arguments should be stripped for repo hook context")
10141019
}
1020+
if got := captured.Metadata["tool_arguments_preview"]; got != "token=***" {
1021+
t.Fatalf("tool_arguments_preview = %v, want token=***", got)
1022+
}
10151023
if _, exists := captured.Metadata["capability_token"]; exists {
10161024
t.Fatal("capability_token should be stripped for repo hook context")
10171025
}
10181026
}
1027+
1028+
func TestExecutorSkipsHookWhenMatcherMissed(t *testing.T) {
1029+
t.Parallel()
1030+
1031+
registry := NewRegistry()
1032+
executor := NewExecutor(registry, nil, 100*time.Millisecond)
1033+
if err := registry.Register(HookSpec{
1034+
ID: "matcher-hook",
1035+
Point: HookPointBeforeToolCall,
1036+
Scope: HookScopeUser,
1037+
Matcher: &HookMatcher{ToolNames: []string{"bash"}},
1038+
Handler: func(context.Context, HookContext) HookResult {
1039+
return HookResult{Status: HookResultPass, Message: "should-not-run"}
1040+
},
1041+
}); err != nil {
1042+
t.Fatalf("Register() error = %v", err)
1043+
}
1044+
1045+
output := executor.Run(context.Background(), HookPointBeforeToolCall, HookContext{
1046+
Metadata: map[string]any{"tool_name": "filesystem"},
1047+
})
1048+
if output.Blocked {
1049+
t.Fatalf("Blocked = true, want false")
1050+
}
1051+
if len(output.Results) != 0 {
1052+
t.Fatalf("len(Results) = %d, want 0 when matcher missed", len(output.Results))
1053+
}
1054+
}
1055+

0 commit comments

Comments
 (0)