Skip to content

Commit 9995db5

Browse files
authored
Merge pull request #506 from Cai-Tang-www/feat/hook-p3
feat(runtime): P3 Repo Hooks + Workspace Trust Gate (#491)
2 parents 67b0108 + 3b6ae2c commit 9995db5

24 files changed

Lines changed: 1926 additions & 123 deletions

docs/guides/configuration.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,52 @@ context:
137137

138138
> 注意:`warn_only` 在 runtime 内部映射为 `fail_open`,表示记录失败但不阻断主链。
139139

140+
### Repo Hooks(P3)
141+
142+
仓库级 hooks 文件路径固定为:
143+
144+
```text
145+
<workspace>/.neocode/hooks.yaml
146+
```
147+
148+
执行受 trust gate 控制,默认不执行。只有当 workspace 出现在 `~/.neocode/trusted-workspaces.json` 中时才会加载。
149+
150+
`hooks.yaml` 示例:
151+
152+
```yaml
153+
hooks:
154+
items:
155+
- id: repo-readme-check
156+
enabled: true
157+
point: before_completion_decision
158+
scope: repo
159+
kind: builtin
160+
mode: sync
161+
handler: require_file_exists
162+
params:
163+
path: README.md
164+
message: "请先补齐 README.md"
165+
```
166+
167+
trust store 示例:
168+
169+
```json
170+
{
171+
"version": 1,
172+
"workspaces": [
173+
"/absolute/path/to/workspace"
174+
]
175+
}
176+
```
177+
178+
约束说明:
179+
180+
- `runtime.hooks.enabled=false` 会关闭全部 hooks(internal/user/repo)。
181+
- repo hooks 仅支持 builtin 子集(3 个 points + 3 个 handlers)。
182+
- 执行顺序固定:`internal -> user -> repo`。
183+
- 跨来源同 ID 允许并存;同来源内重复 ID 会报错。
184+
- trust store 缺失/空文件/损坏 JSON/结构错误时,按 untrusted 处理并发出 `repo_hooks_trust_store_invalid` 事件,不阻断启动。
185+
140186
## Budget 解析规则
141187

142188
NeoCode 已不再使用旧的 `auto_compact` 阈值语义,当前统一使用 `context.budget`:

docs/runtime-hooks-design.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
- P0:hooks core(registry / executor / timeout / panic recover / failure policy / hook events)
1010
- P1:接入 `before_tool_call``after_tool_result``before_completion_decision`
1111
- P2:全局 user builtin hooks(`runtime.hooks`
12+
- P3:repo hooks(`<workspace>/.neocode/hooks.yaml`)+ workspace trust gate(`~/.neocode/trusted-workspaces.json`
1213

1314
当前未实现能力:
1415

15-
- repo hooks(P3)
1616
- async / async_rewake(P5)
1717
- command/http/prompt/agent hooks(P6)
1818

@@ -26,16 +26,39 @@ P2 仅支持:
2626
- 挂载点:`before_tool_call``after_tool_result``before_completion_decision`
2727
- handler:`require_file_exists``warn_on_tool_call``add_context_note`
2828

29-
P2 明确不支持
29+
当前(P3)明确定义
3030

3131
- user hook 修改 tool 输入或 tool result
3232
- user hook 直接写入 provider-facing prompt
33+
- repo hook 修改 tool 输入或 tool result
34+
- repo hook 直接写入 provider-facing prompt
35+
36+
## P3 repo hooks 边界
37+
38+
repo hooks 文件路径固定为:
39+
40+
```text
41+
<workspace>/.neocode/hooks.yaml
42+
```
43+
44+
仅支持与 P2 相同的 builtin 子集(`kind=builtin``mode=sync`、3 个 points、3 个 handlers)。
45+
46+
执行顺序固定为:
47+
48+
```text
49+
internal -> user -> repo
50+
```
51+
52+
冲突规则:
53+
54+
- 同来源内重复 `id`:fail-fast
55+
- 跨来源同 `id`:允许并存(通过 `source` 区分)
3356

3457
## 安全模型
3558

3659
### 上下文裁剪
3760

38-
user hook 接收的 `HookContext` 经过白名单裁剪,仅保留最小必要字段:
61+
user/repo hook 接收的 `HookContext` 经过白名单裁剪,仅保留最小必要字段:
3962

4063
- `run_id` / `session_id`
4164
- `point` / `tool_call_id` / `tool_name`
@@ -48,7 +71,26 @@ user hook 接收的 `HookContext` 经过白名单裁剪,仅保留最小必要
4871

4972
- API key / capability token
5073
- service 指针与 provider 客户端对象
51-
- 原始工具参数明文
74+
- 原始工具参数明文(`tool_arguments`
75+
76+
### trust gate
77+
78+
repo hooks 默认不执行,仅 trusted workspace 会加载执行。
79+
80+
trust store 固定路径:
81+
82+
```text
83+
~/.neocode/trusted-workspaces.json
84+
```
85+
86+
容错行为(统一降级为 untrusted,且不阻断启动):
87+
88+
- 文件缺失
89+
- 空文件
90+
- JSON 损坏
91+
- 结构不匹配
92+
93+
上述异常会发出事件:`repo_hooks_trust_store_invalid`
5294

5395
### 路径约束
5496

@@ -66,9 +108,14 @@ runtime 会透传 hooks 生命周期事件:
66108
- `hook_finished`
67109
- `hook_failed`
68110
- `hook_blocked`
111+
- `repo_hooks_discovered`
112+
- `repo_hooks_loaded`
113+
- `repo_hooks_skipped_untrusted`
114+
- `repo_hooks_trust_store_invalid`
69115

70116
`hook_finished/hook_failed` 包含 `message` 字段,用于承载 warning/note 文本。
71-
user hook 的 `message` 同时进入 runtime 的 annotation buffer(运行态内存缓冲),用于后续观测与诊断。
117+
hook 事件额外携带 `source` 字段;展示层建议使用 `<source>:<id>`
118+
user/repo hook 的 `message` 会进入 runtime 的 annotation buffer(运行态内存缓冲),用于后续观测与诊断。
72119

73120
## 失败策略
74121

internal/runtime/events.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ type HookEventPayload struct {
232232
HookID string `json:"hook_id"`
233233
Point string `json:"point"`
234234
Scope string `json:"scope"`
235+
Source string `json:"source"`
235236
Kind string `json:"kind"`
236237
Mode string `json:"mode"`
237238
Status string `json:"status,omitempty"`
@@ -244,13 +245,29 @@ type HookEventPayload struct {
244245
// HookBlockedPayload 描述 hook 阻断事件负载。
245246
type HookBlockedPayload struct {
246247
HookID string `json:"hook_id"`
248+
Source string `json:"source,omitempty"`
247249
Point string `json:"point"`
248250
ToolCallID string `json:"tool_call_id,omitempty"`
249251
ToolName string `json:"tool_name,omitempty"`
250252
Reason string `json:"reason,omitempty"`
251253
Enforced bool `json:"enforced"`
252254
}
253255

256+
// RepoHooksTrustStoreInvalidPayload 描述 trust store 不可用时的降级信息。
257+
type RepoHooksTrustStoreInvalidPayload struct {
258+
TrustStorePath string `json:"trust_store_path"`
259+
Reason string `json:"reason"`
260+
}
261+
262+
// RepoHooksLifecyclePayload 描述 repo hooks 发现/加载/跳过等生命周期信息。
263+
type RepoHooksLifecyclePayload struct {
264+
Workspace string `json:"workspace"`
265+
HooksPath string `json:"hooks_path,omitempty"`
266+
TrustStorePath string `json:"trust_store_path,omitempty"`
267+
HookCount int `json:"hook_count,omitempty"`
268+
Reason string `json:"reason,omitempty"`
269+
}
270+
254271
const (
255272
// EventUserMessage 表示用户消息已写入会话。
256273
EventUserMessage EventType = "user_message"
@@ -334,6 +351,14 @@ const (
334351
EventHookFailed EventType = "hook_failed"
335352
// EventHookBlocked 表示某个 hook 返回 block(是否生效由 payload.enforced 决定)。
336353
EventHookBlocked EventType = "hook_blocked"
354+
// EventRepoHooksDiscovered 表示检测到仓库 hooks 配置文件。
355+
EventRepoHooksDiscovered EventType = "repo_hooks_discovered"
356+
// EventRepoHooksLoaded 表示仓库 hooks 已加载并进入执行链。
357+
EventRepoHooksLoaded EventType = "repo_hooks_loaded"
358+
// EventRepoHooksSkippedUntrusted 表示仓库未信任导致 repo hooks 被跳过。
359+
EventRepoHooksSkippedUntrusted EventType = "repo_hooks_skipped_untrusted"
360+
// EventRepoHooksTrustStoreInvalid 表示 trust store 缺失或损坏,已降级为 untrusted。
361+
EventRepoHooksTrustStoreInvalid EventType = "repo_hooks_trust_store_invalid"
337362
)
338363

339364
// TokenUsagePayload 承载单轮 token 用量统计。

internal/runtime/hooks/events.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type HookEvent struct {
2323
HookID string
2424
Point HookPoint
2525
Scope HookScope
26+
Source HookSource
2627
Kind HookKind
2728
Mode HookMode
2829
Status HookResultStatus

internal/runtime/hooks/executor.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func (e *Executor) Run(ctx context.Context, point HookPoint, input HookContext)
6161
}
6262
for _, spec := range specs {
6363
hookInput := input.Clone()
64-
if spec.Scope == HookScopeUser {
64+
if spec.Scope == HookScopeUser || spec.Scope == HookScopeRepo {
6565
hookInput = sanitizeUserHookContext(hookInput)
6666
}
6767
result := e.runOne(ctx, spec, hookInput)
@@ -70,11 +70,13 @@ func (e *Executor) Run(ctx context.Context, point HookPoint, input HookContext)
7070
if result.Status == HookResultBlock {
7171
output.Blocked = true
7272
output.BlockedBy = spec.ID
73+
output.BlockedSource = spec.Source
7374
break
7475
}
7576
if result.Status == HookResultFailed && spec.FailurePolicy == FailurePolicyFailClosed {
7677
output.Blocked = true
7778
output.BlockedBy = spec.ID
79+
output.BlockedSource = spec.Source
7880
break
7981
}
8082
}
@@ -88,6 +90,7 @@ func (e *Executor) runOne(ctx context.Context, spec HookSpec, input HookContext)
8890
HookID: spec.ID,
8991
Point: spec.Point,
9092
Scope: spec.Scope,
93+
Source: spec.Source,
9194
Kind: spec.Kind,
9295
Mode: spec.Mode,
9396
StartedAt: startedAt,
@@ -107,6 +110,9 @@ func (e *Executor) runOne(ctx context.Context, spec HookSpec, input HookContext)
107110
if result.Scope == "" {
108111
result.Scope = spec.Scope
109112
}
113+
if result.Source == "" {
114+
result.Source = spec.Source
115+
}
110116
if result.DurationMS <= 0 {
111117
result.DurationMS = durationMS
112118
}
@@ -118,6 +124,7 @@ func (e *Executor) runOne(ctx context.Context, spec HookSpec, input HookContext)
118124
HookID: spec.ID,
119125
Point: spec.Point,
120126
Scope: spec.Scope,
127+
Source: spec.Source,
121128
Kind: spec.Kind,
122129
Mode: spec.Mode,
123130
Status: result.Status,
@@ -131,6 +138,7 @@ func (e *Executor) runOne(ctx context.Context, spec HookSpec, input HookContext)
131138
HookID: spec.ID,
132139
Point: spec.Point,
133140
Scope: spec.Scope,
141+
Source: spec.Source,
134142
Kind: spec.Kind,
135143
Mode: spec.Mode,
136144
Status: result.Status,
@@ -166,6 +174,7 @@ func (e *Executor) callHandler(
166174
HookID: spec.ID,
167175
Point: spec.Point,
168176
Scope: spec.Scope,
177+
Source: spec.Source,
169178
Status: HookResultFailed,
170179
Message: err,
171180
Error: err,
@@ -201,6 +210,7 @@ func (e *Executor) callHandler(
201210
HookID: spec.ID,
202211
Point: spec.Point,
203212
Scope: spec.Scope,
213+
Source: spec.Source,
204214
Status: HookResultFailed,
205215
Message: err,
206216
Error: err,
@@ -213,6 +223,7 @@ func (e *Executor) callHandler(
213223
HookID: spec.ID,
214224
Point: spec.Point,
215225
Scope: spec.Scope,
226+
Source: spec.Source,
216227
Status: HookResultFailed,
217228
Message: err,
218229
Error: err,
@@ -222,6 +233,7 @@ func (e *Executor) callHandler(
222233
outcome.result.HookID = spec.ID
223234
outcome.result.Point = spec.Point
224235
outcome.result.Scope = spec.Scope
236+
outcome.result.Source = spec.Source
225237
if outcome.result.Status == "" {
226238
outcome.result.Status = HookResultPass
227239
}
@@ -233,6 +245,7 @@ func (e *Executor) callHandler(
233245
HookID: spec.ID,
234246
Point: spec.Point,
235247
Scope: spec.Scope,
248+
Source: spec.Source,
236249
Status: HookResultFailed,
237250
Message: err,
238251
Error: err,

internal/runtime/hooks/executor_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,3 +704,44 @@ func TestExecutorSanitizeUserHookContext(t *testing.T) {
704704
t.Fatal("capability_token should be stripped for user hook context")
705705
}
706706
}
707+
708+
func TestExecutorSanitizeRepoHookContext(t *testing.T) {
709+
t.Parallel()
710+
711+
registry := NewRegistry()
712+
executor := NewExecutor(registry, nil, 100*time.Millisecond)
713+
var captured HookContext
714+
if err := registry.Register(HookSpec{
715+
ID: "repo-hook",
716+
Point: HookPointBeforeToolCall,
717+
Scope: HookScopeRepo,
718+
Source: HookSourceRepo,
719+
Handler: func(_ context.Context, input HookContext) HookResult {
720+
captured = input
721+
return HookResult{Status: HookResultPass}
722+
},
723+
}); err != nil {
724+
t.Fatalf("Register() error = %v", err)
725+
}
726+
727+
_ = executor.Run(context.Background(), HookPointBeforeToolCall, HookContext{
728+
RunID: "run-1",
729+
SessionID: "session-1",
730+
Metadata: map[string]any{
731+
"tool_name": "bash",
732+
"tool_arguments": "--secret-token=abc",
733+
"capability_token": "should-not-leak",
734+
"workdir": "/tmp/work",
735+
},
736+
})
737+
738+
if got := captured.Metadata["tool_name"]; got != "bash" {
739+
t.Fatalf("tool_name = %v, want bash", got)
740+
}
741+
if _, exists := captured.Metadata["tool_arguments"]; exists {
742+
t.Fatal("tool_arguments should be stripped for repo hook context")
743+
}
744+
if _, exists := captured.Metadata["capability_token"]; exists {
745+
t.Fatal("capability_token should be stripped for repo hook context")
746+
}
747+
}

internal/runtime/hooks/registry.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ func (r *Registry) Register(spec HookSpec) error {
4343

4444
r.mu.Lock()
4545
defer r.mu.Unlock()
46-
if _, exists := r.hooks[normalized.ID]; exists {
47-
return fmt.Errorf("%w: %s", ErrHookAlreadyExists, normalized.ID)
46+
key := registryHookKey(normalized.Source, normalized.ID)
47+
if _, exists := r.hooks[key]; exists {
48+
return fmt.Errorf("%w: %s:%s", ErrHookAlreadyExists, normalized.Source, normalized.ID)
4849
}
4950

5051
r.seq++
51-
r.hooks[normalized.ID] = registryEntry{
52+
r.hooks[key] = registryEntry{
5253
spec: normalized,
5354
seq: r.seq,
5455
}
@@ -67,10 +68,16 @@ func (r *Registry) Remove(id string) error {
6768

6869
r.mu.Lock()
6970
defer r.mu.Unlock()
70-
if _, exists := r.hooks[id]; !exists {
71+
removed := false
72+
for key, entry := range r.hooks {
73+
if strings.EqualFold(strings.TrimSpace(entry.spec.ID), id) {
74+
delete(r.hooks, key)
75+
removed = true
76+
}
77+
}
78+
if !removed {
7179
return fmt.Errorf("%w: %s", ErrHookNotFound, id)
7280
}
73-
delete(r.hooks, id)
7481
return nil
7582
}
7683

@@ -110,3 +117,7 @@ func (r *Registry) Resolve(point HookPoint) []HookSpec {
110117
}
111118
return out
112119
}
120+
121+
func registryHookKey(source HookSource, id string) string {
122+
return strings.ToLower(strings.TrimSpace(string(source))) + "\x00" + strings.ToLower(strings.TrimSpace(id))
123+
}

0 commit comments

Comments
 (0)