Skip to content

Commit b372019

Browse files
authored
Merge pull request #715 from Cai-Tang-www/codex/feat-hook-payload-schema-685
feat(hooks): publish and version hook payload schema
2 parents 7d583b7 + 18ba6a7 commit b372019

12 files changed

Lines changed: 1997 additions & 87 deletions

docs/reference/hook-payload.v1.json

Lines changed: 1301 additions & 0 deletions
Large diffs are not rendered by default.

docs/runtime-hooks-design.md

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ repo hooks 文件路径固定为:
5555
<workspace>/.neocode/hooks.yaml
5656
```
5757

58-
仅支持与 P2 相同的 builtin 子集(`kind=builtin``mode=sync``UserAllowed=true` points、3 个 handlers)。
59-
repo hooks 暂不支持 `kind=http`external kinds`command/http/prompt/agent`)在 repo 侧仍显式拒绝
58+
支持与 P2 相同的 builtin 子集(`kind=builtin``mode=sync``UserAllowed=true` points、3 个 handlers),并开放 `kind=command + mode=sync`
59+
repo hooks 仍不支持 `kind=http`external kinds 中当前仅 `command` 开放,`prompt/agent` 仍显式拒绝
6060

6161
执行顺序固定为:
6262

@@ -73,22 +73,55 @@ internal -> user -> repo
7373

7474
### 上下文裁剪
7575

76-
user/repo hook 接收的 `HookContext` 经过白名单裁剪,仅保留最小必要字段:
76+
user/repo hook 接收的 `HookContext` 经过点位感知的 payload schema 裁剪,仅保留最小必要字段:
7777

78-
- `run_id` / `session_id`
79-
- `point` / `tool_call_id` / `tool_name`
80-
- `tool_arguments_preview`(脱敏+截断后的参数预览)
81-
- `is_error` / `error_class`
82-
- `result_content_preview` / `result_metadata_present`
83-
- `execution_error`
84-
- `workdir`
78+
- 顶层字段:`run_id` / `session_id`
79+
- metadata 字段:按 `point` 不同暴露对应的最小子集,例如 `tool_call_id``tool_name``workdir`
80+
- 预览/摘要类字段:`tool_arguments_preview``result_content_preview``todo_summary``recent_tool_summary`
81+
- 完整机器可读契约见 `docs/reference/hook-payload.v1.json`
8582

8683
不会暴露:
8784

8885
- API key / capability token
8986
- service 指针与 provider 客户端对象
9087
- 原始工具参数明文(`tool_arguments`
9188

89+
### Payload Schema
90+
91+
Hook payload 已从 runtime 内部白名单提升为公开契约:
92+
93+
- 版本号真源:`internal/runtime/hooks.PayloadVersion`
94+
- 当前版本:`payload_version: "1"`
95+
- 生成命令:`go generate ./internal/runtime/hooks`
96+
- 生成产物:`docs/reference/hook-payload.v1.json`
97+
- command stdin 与 HTTP observe body 都带 `payload_version`
98+
- HTTP observe 在共享字段之外,继续保留 `scope``kind``mode``triggered_at` 作为 transport 附加字段
99+
- `metadata.point``completion_passed``has_tool_calls``assistant_role` 这类未由 runtime 真实生产的字段不再公开
100+
101+
稳定性分级:
102+
103+
- `stable`:身份/控制类字段,默认兼容承诺
104+
- `experimental`:预览/摘要类字段,当前包括 `tool_arguments_preview``result_content_preview``todo_summary``recent_tool_summary`
105+
- `deprecated`:保留给后续版本迁移,本版未使用
106+
107+
各点位 metadata 字段:
108+
109+
| point | metadata fields |
110+
|---|---|
111+
| `before_tool_call` | `run_id`, `session_id`, `tool_call_id`, `tool_name`, `tool_arguments_preview` (experimental), `workdir` |
112+
| `after_tool_result` | `run_id`, `session_id`, `tool_call_id`, `tool_name`, `is_error`, `error_class`, `result_content_preview` (experimental), `result_metadata_present`, `execution_error`, `workdir` |
113+
| `before_completion_decision` | `run_id`, `session_id` |
114+
| `accept_gate` | `run_id`, `session_id`, `workdir`, `workspace_changed`, `assistant_text_empty`, `todo_summary` (experimental), `recent_tool_summary` (experimental) |
115+
| `before_permission_decision` | `run_id`, `session_id`, `tool_call_id`, `tool_name`, `decision`, `reason`, `rule_id`, `workdir` |
116+
| `after_tool_failure` | `run_id`, `session_id`, `tool_call_id`, `tool_name`, `tool_arguments_preview` (experimental), `is_error`, `error_class`, `execution_error`, `result_content_preview` (experimental), `workdir` |
117+
| `session_start` | `run_id`, `session_id`, `workdir` |
118+
| `session_end` | `run_id`, `session_id`, `stop_reason`, `detail` |
119+
| `user_prompt_submit` | `run_id`, `session_id`, `workdir` |
120+
| `pre_compact` | `run_id`, `session_id`, `workdir`, `trigger_mode` |
121+
| `post_compact` | `run_id`, `session_id`, `workdir`, `trigger_mode`, `applied` |
122+
| `subagent_start` | `run_id`, `session_id`, `task_id`, `role`, `workspace`, `tool_name`, `trigger`, `workdir` |
123+
| `subagent_stop` | `run_id`, `session_id`, `task_id`, `role`, `state`, `stop_reason`, `step_count`, `error` |
124+
92125
### 点位能力矩阵(P4)
93126

94127
runtime 内置 `HookPointCapability` 作为唯一真源,定义每个点位是否允许 block/observe/update_input 以及是否允许 user/repo 挂载。
@@ -184,6 +217,7 @@ trust store 固定路径:
184217
- `hook_id`:hook 配置中的 `id`
185218
- `point`:触发点位名称
186219
- `metadata`:经白名单裁剪后的上下文字段(与 builtin/http hook 相同的 allowlist)
220+
- 完整字段表与稳定性分级见 `docs/reference/hook-payload.v1.json`
187221

188222
### stdout 协议
189223

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
runtimehooks "neo-code/internal/runtime/hooks"
9+
)
10+
11+
func main() {
12+
content, err := runtimehooks.MarshalPayloadJSONSchema()
13+
if err != nil {
14+
fail(err)
15+
}
16+
targetPath := filepath.Clean(filepath.Join("..", "..", "..", "docs", "reference", schemaFileName()))
17+
if err := os.WriteFile(targetPath, content, 0o644); err != nil {
18+
fail(err)
19+
}
20+
}
21+
22+
func schemaFileName() string {
23+
return fmt.Sprintf("hook-payload.v%s.json", runtimehooks.PayloadVersion)
24+
}
25+
26+
func fail(err error) {
27+
_, _ = fmt.Fprintf(os.Stderr, "generate hook payload schema: %v\n", err)
28+
os.Exit(1)
29+
}

internal/runtime/hooks/command_handler.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
)
1515

1616
// CommandHookPayloadVersion 定义 command hook stdin 协议版本号,变更 stdin 结构时递增。
17-
const CommandHookPayloadVersion = "1"
17+
const CommandHookPayloadVersion = PayloadVersion
1818

1919
// maxCommandStdoutBytes 限制外部命令 stdout 最大读取字节数,防止 OOM。
2020
const maxCommandStdoutBytes = 1 << 20 // 1 MiB
@@ -109,14 +109,14 @@ func ParseCommandParams(params map[string]any) (argv []string, shell bool, err e
109109
// BuildCommandPayload 构造传给外部命令的 stdin JSON payload。
110110
func BuildCommandPayload(hookID string, point HookPoint, input HookContext) CommandHookPayload {
111111
payload := CommandHookPayload{
112-
PayloadVersion: CommandHookPayloadVersion,
112+
PayloadVersion: PayloadVersion,
113113
HookID: strings.TrimSpace(hookID),
114114
Point: string(point),
115115
RunID: strings.TrimSpace(input.RunID),
116116
SessionID: strings.TrimSpace(input.SessionID),
117117
}
118-
if len(input.Metadata) > 0 {
119-
payload.Metadata = input.Metadata
118+
if metadata := sanitizePayloadMetadata(point, input.Metadata); len(metadata) > 0 {
119+
payload.Metadata = metadata
120120
}
121121
return payload
122122
}
@@ -256,7 +256,7 @@ func buildCommandEnv(spec CommandHookSpec) []string {
256256
env := []string{
257257
"NEOCODE_HOOK_HOOK_ID=" + spec.HookID,
258258
"NEOCODE_HOOK_POINT=" + string(spec.Point),
259-
"NEOCODE_HOOK_PAYLOAD_VERSION=" + CommandHookPayloadVersion,
259+
"NEOCODE_HOOK_PAYLOAD_VERSION=" + PayloadVersion,
260260
}
261261
if runtime.GOOS == "windows" {
262262
for _, key := range []string{"SystemRoot", "SystemDrive", "USERPROFILE"} {

internal/runtime/hooks/command_handler_test.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ func TestBuildCommandPayload(t *testing.T) {
1818
RunID: "run-123",
1919
SessionID: "sess-456",
2020
Metadata: map[string]any{
21-
"tool_name": "bash",
22-
"workdir": "/tmp",
21+
"tool_name": "bash",
22+
"workdir": "/tmp",
23+
"tool_arguments": "rm -rf /tmp",
24+
"capability_token": "secret",
2325
},
2426
})
25-
if payload.PayloadVersion != CommandHookPayloadVersion {
26-
t.Fatalf("payload_version = %q, want %q", payload.PayloadVersion, CommandHookPayloadVersion)
27+
if payload.PayloadVersion != PayloadVersion {
28+
t.Fatalf("payload_version = %q, want %q", payload.PayloadVersion, PayloadVersion)
2729
}
2830
if payload.HookID != "my-hook" {
2931
t.Fatalf("hook_id = %q, want %q", payload.HookID, "my-hook")
@@ -40,6 +42,12 @@ func TestBuildCommandPayload(t *testing.T) {
4042
if payload.Metadata["tool_name"] != "bash" {
4143
t.Fatalf("metadata[tool_name] = %v, want %q", payload.Metadata["tool_name"], "bash")
4244
}
45+
if _, exists := payload.Metadata["tool_arguments"]; exists {
46+
t.Fatal("metadata[tool_arguments] should be stripped by payload schema")
47+
}
48+
if _, exists := payload.Metadata["capability_token"]; exists {
49+
t.Fatal("metadata[capability_token] should be stripped by payload schema")
50+
}
4351
}
4452

4553
func TestBuildCommandPayloadEmptyMetadata(t *testing.T) {
@@ -723,13 +731,13 @@ func TestRunCommandHookStdinPayloadWithMetadata(t *testing.T) {
723731
if runtime.GOOS == "windows" {
724732
spec = CommandHookSpec{
725733
HookID: "stdin-meta",
726-
Point: HookPointUserPromptSubmit,
734+
Point: HookPointBeforeToolCall,
727735
Command: []string{"powershell", "-Command", "$input"},
728736
}
729737
} else {
730738
spec = CommandHookSpec{
731739
HookID: "stdin-meta",
732-
Point: HookPointUserPromptSubmit,
740+
Point: HookPointBeforeToolCall,
733741
Command: []string{"cat"},
734742
}
735743
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package hooks
2+
3+
//go:generate go run ./cmd/hookpayloadschema

internal/runtime/hooks/executor.go

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func (e *Executor) Run(ctx context.Context, point HookPoint, input HookContext)
7777
for _, spec := range specs {
7878
hookInput := input.Clone()
7979
if spec.Scope == HookScopeUser || spec.Scope == HookScopeRepo {
80-
hookInput = sanitizeUserHookContext(hookInput)
80+
hookInput = sanitizeUserHookContext(spec.Point, hookInput)
8181
}
8282
if spec.Matcher != nil && !spec.Matcher.Match(hookInput) {
8383
continue
@@ -331,59 +331,12 @@ func (e *Executor) callHandler(
331331
}
332332
}
333333

334-
func sanitizeUserHookContext(input HookContext) HookContext {
334+
func sanitizeUserHookContext(point HookPoint, input HookContext) HookContext {
335335
sanitized := HookContext{
336336
RunID: strings.TrimSpace(input.RunID),
337337
SessionID: strings.TrimSpace(input.SessionID),
338338
}
339-
if len(input.Metadata) == 0 {
340-
return sanitized
341-
}
342-
allowedMetadataKeys := map[string]struct{}{
343-
"point": {},
344-
"tool_call_id": {},
345-
"tool_name": {},
346-
"tool_arguments_preview": {},
347-
"is_error": {},
348-
"error_class": {},
349-
"result_content_preview": {},
350-
"result_metadata_present": {},
351-
"execution_error": {},
352-
"workdir": {},
353-
"session_id": {},
354-
"run_id": {},
355-
"task_id": {},
356-
"role": {},
357-
"workspace": {},
358-
"trigger": {},
359-
"state": {},
360-
"stop_reason": {},
361-
"step_count": {},
362-
"error": {},
363-
"trigger_mode": {},
364-
"applied": {},
365-
"decision": {},
366-
"reason": {},
367-
"rule_id": {},
368-
"completion_passed": {},
369-
"has_tool_calls": {},
370-
"assistant_role": {},
371-
"detail": {},
372-
"workspace_changed": {},
373-
"assistant_text_empty": {},
374-
"todo_summary": {},
375-
"recent_tool_summary": {},
376-
}
377-
for key, value := range input.Metadata {
378-
normalizedKey := strings.ToLower(strings.TrimSpace(key))
379-
if _, ok := allowedMetadataKeys[normalizedKey]; !ok {
380-
continue
381-
}
382-
if sanitized.Metadata == nil {
383-
sanitized.Metadata = make(map[string]any, len(input.Metadata))
384-
}
385-
sanitized.Metadata[normalizedKey] = cloneMetadataValue(value)
386-
}
339+
sanitized.Metadata = sanitizePayloadMetadata(point, input.Metadata)
387340
return sanitized
388341
}
389342

internal/runtime/hooks/executor_test.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,8 @@ func TestExecutorSanitizeUserHookContext(t *testing.T) {
959959
"tool_arguments": "--secret-token=abc",
960960
"tool_arguments_preview": "token=***",
961961
"capability_token": "should-not-leak",
962+
"completion_passed": true,
963+
"assistant_role": "assistant",
962964
"workdir": "/tmp/work",
963965
},
964966
})
@@ -978,6 +980,12 @@ func TestExecutorSanitizeUserHookContext(t *testing.T) {
978980
if _, exists := captured.Metadata["capability_token"]; exists {
979981
t.Fatal("capability_token should be stripped for user hook context")
980982
}
983+
if _, exists := captured.Metadata["completion_passed"]; exists {
984+
t.Fatal("completion_passed should be stripped when not produced by runtime")
985+
}
986+
if _, exists := captured.Metadata["assistant_role"]; exists {
987+
t.Fatal("assistant_role should be stripped when not produced by runtime")
988+
}
981989
}
982990

983991
func TestExecutorSanitizeRepoHookContext(t *testing.T) {
@@ -1007,6 +1015,8 @@ func TestExecutorSanitizeRepoHookContext(t *testing.T) {
10071015
"tool_arguments": "--secret-token=abc",
10081016
"tool_arguments_preview": "token=***",
10091017
"capability_token": "should-not-leak",
1018+
"completion_passed": true,
1019+
"assistant_role": "assistant",
10101020
"workdir": "/tmp/work",
10111021
},
10121022
})
@@ -1023,6 +1033,12 @@ func TestExecutorSanitizeRepoHookContext(t *testing.T) {
10231033
if _, exists := captured.Metadata["capability_token"]; exists {
10241034
t.Fatal("capability_token should be stripped for repo hook context")
10251035
}
1036+
if _, exists := captured.Metadata["completion_passed"]; exists {
1037+
t.Fatal("completion_passed should be stripped for repo hook context")
1038+
}
1039+
if _, exists := captured.Metadata["assistant_role"]; exists {
1040+
t.Fatal("assistant_role should be stripped for repo hook context")
1041+
}
10261042
}
10271043

10281044
func TestExecutorSkipsHookWhenMatcherMissed(t *testing.T) {
@@ -1052,4 +1068,3 @@ func TestExecutorSkipsHookWhenMatcherMissed(t *testing.T) {
10521068
t.Fatalf("len(Results) = %d, want 0 when matcher missed", len(output.Results))
10531069
}
10541070
}
1055-

0 commit comments

Comments
 (0)