Skip to content
Open
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/examples/hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ hooks:
kind: builtin
mode: sync
handler: warn_on_tool_call
match:
tool_name: ["bash"]
params:
tool_names: ["bash"]
message: "执行 bash 前请确认命令不会破坏工作区。"

- id: require-readme-before-final
Expand Down
3 changes: 2 additions & 1 deletion docs/examples/user-hooks-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ runtime:
kind: builtin
mode: sync
handler: warn_on_tool_call
match:
tool_name: ["bash"]
params:
tool_names: ["bash"]
message: "执行 bash 前请确认命令不会破坏工作区。"

- id: user-http-observe
Expand Down
22 changes: 22 additions & 0 deletions docs/runtime-hooks-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ P2 仅支持:
`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`
- `match`:统一 matcher DSL(字段间 AND、同字段多值 OR),支持:
- `tool_name`:精确匹配(`string` 或 `[]string`)
- `tool_name_regex`:正则匹配(`string` 或 `[]string`,单条最长 256)
- `arguments_contains`:参数预览包含匹配(`[]string`)
- `kind=http + mode=observe`:允许发送 HTTP 观测回调(不支持 block)
- `http observe` 默认不携带 metadata(`include_metadata=false`);即使显式开启也会剥离 `result_content_preview`、`execution_error`
- `http observe` 回调端点仅允许 loopback 地址(`localhost` / `127.0.0.1` / `::1`),避免误配为公网外发
Expand Down Expand Up @@ -73,6 +77,7 @@ user/repo hook 接收的 `HookContext` 经过白名单裁剪,仅保留最小

- `run_id` / `session_id`
- `point` / `tool_call_id` / `tool_name`
- `tool_arguments_preview`(脱敏+截断后的参数预览)
- `is_error` / `error_class`
- `result_content_preview` / `result_metadata_present`
- `execution_error`
Expand Down Expand Up @@ -109,6 +114,23 @@ runtime 内置 `HookPointCapability` 作为唯一真源,定义每个点位是
- `CanBlock=false` 的点位,hook 返回 `block` 会自动降级为观测结果,不中断主链。
- `CanUpdateInput` 在 `user_prompt_submit` 点位已开放:command hook 可通过 stdout JSON 的 `update_input` 字段改写用户输入。
- `UserAllowed=false` 的点位拒绝 user/repo 挂载(配置 fail-fast)。
- matcher 字段会按点位能力矩阵做 fail-fast:不支持的维度会在配置加载阶段直接报错。

### matcher 点位维度矩阵(#684)

| point | tool_name | tool_name_regex | arguments_contains |
|---|---|---|---|
| `before_tool_call` | ✅ | ✅ | ✅ |
| `after_tool_result` | ✅ | ✅ | ❌ |
| `after_tool_failure` | ✅ | ✅ | ✅ |
| `before_permission_decision` | ✅ | ✅ | ❌ |
| 其他点位 | ❌ | ❌ | ❌ |

说明:

- `arguments_contains` 基于 `tool_arguments_preview` 字段匹配,不读取 `tool_arguments` 原文。
- `warn_on_tool_call` 的旧参数 `params.tool_name/tool_names` 仍兼容;未配置 `match` 时会自动桥接为 matcher。
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation now rejects warn_on_tool_call unless match is present and no longer bridges params.tool_name/tool_names or emits a migration warning. These bullets still document the removed compatibility path, which will mislead users migrating hook configs. Please update this section to match the current behavior.

- 若 `match` 与旧参数共存,以 `match` 为准,并发出 `hook_notification` 迁移提示事件。

### trust gate

Expand Down
3 changes: 2 additions & 1 deletion internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ runtime:
priority: 100
timeout_sec: 2
failure_policy: warn_only
params:
match:
tool_name: bash
params:
message: "bash is called"
`
writeLoaderConfig(t, loader, raw)
Expand Down
58 changes: 25 additions & 33 deletions internal/config/runtime_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type RuntimeHookItemConfig struct {
Kind string `yaml:"kind,omitempty"`
Mode string `yaml:"mode,omitempty"`
Handler string `yaml:"handler,omitempty"`
Match map[string]any `yaml:"match,omitempty"`
Priority int `yaml:"priority,omitempty"`
TimeoutSec int `yaml:"timeout_sec,omitempty"`
FailurePolicy string `yaml:"failure_policy,omitempty"`
Expand Down Expand Up @@ -189,6 +190,12 @@ func (c RuntimeHookItemConfig) Clone() RuntimeHookItemConfig {
if c.Enabled != nil {
cloned.Enabled = boolPtr(*c.Enabled)
}
if len(c.Match) > 0 {
cloned.Match = make(map[string]any, len(c.Match))
for key, value := range c.Match {
cloned.Match[key] = cloneRuntimeHookParamValue(value)
}
}
if len(c.Params) > 0 {
cloned.Params = make(map[string]any, len(c.Params))
for key, value := range c.Params {
Expand Down Expand Up @@ -279,23 +286,38 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error {
default:
return fmt.Errorf("handler %q is not supported", c.Handler)
}
if handler == runtimeHookHandlerWarnOnToolCall && !hasWarnOnToolCallTargets(c.Params) {
return fmt.Errorf("handler %q requires params.tool_name or params.tool_names", c.Handler)
}
if handler == runtimeHookHandlerWarnOnToolCall && !hooks.HasHookMatcherConfig(c.Match) {
return fmt.Errorf("handler %q requires match", c.Handler)
}
if hooks.HasHookMatcherConfig(c.Match) {
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
return fmt.Errorf("match: %w", err)
}
}
case runtimeHookKindCommand:
if normalizedMode != runtimeHookModeSync {
return fmt.Errorf("mode %q is not supported for kind command (only sync)", c.Mode)
}
if err := hooks.ValidateCommandParams(c.Params); err != nil {
return err
}
if hooks.HasHookMatcherConfig(c.Match) {
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
return fmt.Errorf("match: %w", err)
}
}
case runtimeHookKindHTTP:
if normalizedMode != runtimeHookModeObserve {
return fmt.Errorf("mode %q is not supported for kind http (only observe)", c.Mode)
}
if err := validateRuntimeHTTPObserveItem(c, policy); err != nil {
return err
}
if hooks.HasHookMatcherConfig(c.Match) {
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
return fmt.Errorf("match: %w", err)
}
}
}
return nil
}
Expand Down Expand Up @@ -398,35 +420,6 @@ func cloneRuntimeHookParamValue(value any) any {
}
}

func hasWarnOnToolCallTargets(params map[string]any) bool {
if len(params) == 0 {
return false
}
toolNameRaw, hasToolName := params["tool_name"]
if hasToolName && strings.TrimSpace(fmt.Sprintf("%v", toolNameRaw)) != "" {
return true
}
toolNamesRaw, hasToolNames := params["tool_names"]
if !hasToolNames || toolNamesRaw == nil {
return false
}
switch typed := toolNamesRaw.(type) {
case []string:
for _, item := range typed {
if strings.TrimSpace(item) != "" {
return true
}
}
case []any:
for _, item := range typed {
if strings.TrimSpace(fmt.Sprintf("%v", item)) != "" {
return true
}
}
}
return false
}

// readRuntimeHookParamString 以兼容方式读取 runtime hook 参数中的字符串值。
func readRuntimeHookParamString(params map[string]any, key string) string {
if len(params) == 0 {
Expand All @@ -443,4 +436,3 @@ func readRuntimeHookParamString(params map[string]any, key string) string {
return fmt.Sprintf("%v", typed)
}
}

92 changes: 76 additions & 16 deletions internal/config/runtime_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,15 @@ func TestRuntimeHooksConfigItemDefaultsAndClone(t *testing.T) {
ID: "warn-bash",
Point: string(hooks.HookPointBeforeToolCall),
Handler: runtimeHookHandlerWarnOnToolCall,
Params: map[string]any{
Match: map[string]any{
"tool_name": "bash",
"tags": []any{"warn", "tool"},
},
Params: map[string]any{
"tags": []any{"warn", "tool"},
},
},
},
}
}
cfg.ApplyDefaults(defaultRuntimeHooksConfig())

item := cfg.Items[0]
Expand Down Expand Up @@ -489,6 +491,65 @@ func TestRuntimeHooksConfigValidateWarnOnToolCallRequiresTarget(t *testing.T) {
}
}

func TestRuntimeHooksConfigValidateWarnOnToolCallAllowsMatchWithoutLegacyTargets(t *testing.T) {
t.Parallel()

cfg := RuntimeHooksConfig{
Enabled: boolPtr(true),
UserHooksEnabled: boolPtr(true),
DefaultTimeoutSec: 2,
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
Items: []RuntimeHookItemConfig{
{
ID: "warn-with-match",
Point: string(hooks.HookPointBeforeToolCall),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Handler: runtimeHookHandlerWarnOnToolCall,
TimeoutSec: 2,
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
Match: map[string]any{
"tool_name": "bash",
},
},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}

func TestRuntimeHooksConfigValidateRejectsUnsupportedMatcherDimensionForPoint(t *testing.T) {
t.Parallel()

cfg := RuntimeHooksConfig{
Enabled: boolPtr(true),
UserHooksEnabled: boolPtr(true),
DefaultTimeoutSec: 2,
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
Items: []RuntimeHookItemConfig{
{
ID: "session-start-match",
Point: string(hooks.HookPointSessionStart),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindBuiltIn,
Mode: runtimeHookModeSync,
Handler: runtimeHookHandlerAddContextNote,
TimeoutSec: 2,
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
Params: map[string]any{"note": "observe"},
Match: map[string]any{
"tool_name": "bash",
},
},
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("expected unsupported matcher dimension to fail validation")
}
}

func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
t.Parallel()

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

if hasWarnOnToolCallTargets(nil) {
t.Fatal("nil params should be false")
}
if !hasWarnOnToolCallTargets(map[string]any{"tool_name": "bash"}) {
t.Fatal("tool_name should pass")
}
if !hasWarnOnToolCallTargets(map[string]any{"tool_names": []string{"", "bash"}}) {
t.Fatal("tool_names []string should pass")
}
if !hasWarnOnToolCallTargets(map[string]any{"tool_names": []any{"", "bash"}}) {
t.Fatal("tool_names []any should pass")

matchCfg := RuntimeHookItemConfig{
Match: map[string]any{
"tool_name_regex": []any{`^bash$`},
},
}
if hasWarnOnToolCallTargets(map[string]any{"tool_names": "bash"}) {
t.Fatal("tool_names scalar should fail")
clonedCfg := matchCfg.Clone()
clonedRegexes := clonedCfg.Match["tool_name_regex"].([]any)
clonedRegexes[0] = "^filesystem$"
clonedCfg.Match["tool_name_regex"] = clonedRegexes
originalRegexes := matchCfg.Match["tool_name_regex"].([]any)
if originalRegexes[0] == "^filesystem$" {
t.Fatal("expected match field to be deep-cloned")
}
})
}
Expand Down
4 changes: 4 additions & 0 deletions internal/runtime/hooks/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func (e *Executor) Run(ctx context.Context, point HookPoint, input HookContext)
if spec.Scope == HookScopeUser || spec.Scope == HookScopeRepo {
hookInput = sanitizeUserHookContext(hookInput)
}
if spec.Matcher != nil && !spec.Matcher.Match(hookInput) {
continue
}
if spec.Mode == HookModeAsync || spec.Mode == HookModeAsyncRewake {
e.runAsync(ctx, spec, hookInput)
continue
Expand Down Expand Up @@ -340,6 +343,7 @@ func sanitizeUserHookContext(input HookContext) HookContext {
"point": {},
"tool_call_id": {},
"tool_name": {},
"tool_arguments_preview": {},
"is_error": {},
"error_class": {},
"result_content_preview": {},
Expand Down
53 changes: 45 additions & 8 deletions internal/runtime/hooks/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -955,10 +955,11 @@ func TestExecutorSanitizeUserHookContext(t *testing.T) {
RunID: "run-1",
SessionID: "session-1",
Metadata: map[string]any{
"tool_name": "bash",
"tool_arguments": "--secret-token=abc",
"capability_token": "should-not-leak",
"workdir": "/tmp/work",
"tool_name": "bash",
"tool_arguments": "--secret-token=abc",
"tool_arguments_preview": "token=***",
"capability_token": "should-not-leak",
"workdir": "/tmp/work",
},
})

Expand All @@ -971,6 +972,9 @@ func TestExecutorSanitizeUserHookContext(t *testing.T) {
if _, exists := captured.Metadata["tool_arguments"]; exists {
t.Fatal("tool_arguments should be stripped for user hook context")
}
if got := captured.Metadata["tool_arguments_preview"]; got != "token=***" {
t.Fatalf("tool_arguments_preview = %v, want token=***", got)
}
if _, exists := captured.Metadata["capability_token"]; exists {
t.Fatal("capability_token should be stripped for user hook context")
}
Expand Down Expand Up @@ -999,10 +1003,11 @@ func TestExecutorSanitizeRepoHookContext(t *testing.T) {
RunID: "run-1",
SessionID: "session-1",
Metadata: map[string]any{
"tool_name": "bash",
"tool_arguments": "--secret-token=abc",
"capability_token": "should-not-leak",
"workdir": "/tmp/work",
"tool_name": "bash",
"tool_arguments": "--secret-token=abc",
"tool_arguments_preview": "token=***",
"capability_token": "should-not-leak",
"workdir": "/tmp/work",
},
})

Expand All @@ -1012,7 +1017,39 @@ func TestExecutorSanitizeRepoHookContext(t *testing.T) {
if _, exists := captured.Metadata["tool_arguments"]; exists {
t.Fatal("tool_arguments should be stripped for repo hook context")
}
if got := captured.Metadata["tool_arguments_preview"]; got != "token=***" {
t.Fatalf("tool_arguments_preview = %v, want token=***", got)
}
if _, exists := captured.Metadata["capability_token"]; exists {
t.Fatal("capability_token should be stripped for repo hook context")
}
}

func TestExecutorSkipsHookWhenMatcherMissed(t *testing.T) {
t.Parallel()

registry := NewRegistry()
executor := NewExecutor(registry, nil, 100*time.Millisecond)
if err := registry.Register(HookSpec{
ID: "matcher-hook",
Point: HookPointBeforeToolCall,
Scope: HookScopeUser,
Matcher: &HookMatcher{ToolNames: []string{"bash"}},
Handler: func(context.Context, HookContext) HookResult {
return HookResult{Status: HookResultPass, Message: "should-not-run"}
},
}); err != nil {
t.Fatalf("Register() error = %v", err)
}

output := executor.Run(context.Background(), HookPointBeforeToolCall, HookContext{
Metadata: map[string]any{"tool_name": "filesystem"},
})
if output.Blocked {
t.Fatalf("Blocked = true, want false")
}
if len(output.Results) != 0 {
t.Fatalf("len(Results) = %d, want 0 when matcher missed", len(output.Results))
}
}

Loading
Loading