Skip to content

Commit 560c6fe

Browse files
authored
Merge pull request #694 from Cai-Tang-www/feat/command-hook-protocol-683
feat(runtime): P6 command hook stdin/stdout JSON protocol
2 parents 040be84 + 7db5e5e commit 560c6fe

12 files changed

Lines changed: 1821 additions & 55 deletions

docs/runtime-hooks-design.md

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
- P4:生命周期点位扩展(permission/session/compact/subagent)+ 点位能力矩阵
1414
- P5:internal hooks 支持 `async/async_rewake` + run 内存通知队列(ephemeral 注入)
1515
- P6-lite:user `http/observe` hooks(仅观测回调)
16+
- P6:user/repo `command` hooks(stdin/stdout JSON 协议)
1617

1718
当前未实现能力:
1819

19-
- command/prompt/agent hooks(P6)
20+
- prompt/agent hooks(P6)
2021

2122
## P2 user hooks 边界
2223

@@ -32,7 +33,8 @@ P2 仅支持:
3233
- `kind=http + mode=observe`:允许发送 HTTP 观测回调(不支持 block)
3334
- `http observe` 默认不携带 metadata(`include_metadata=false`);即使显式开启也会剥离 `result_content_preview``execution_error`
3435
- `http observe` 回调端点仅允许 loopback 地址(`localhost` / `127.0.0.1` / `::1`),避免误配为公网外发
35-
- external kinds 中 `command/prompt/agent` 在 P6-lite 阶段显式拒绝,不会半生效
36+
- `kind=command + mode=sync`:允许执行外部命令,通过 stdin/stdout JSON 协议通信(详见下方 P6 章节)
37+
- external kinds 中 `prompt/agent` 仍显式拒绝
3638

3739
当前(P3)明确不支持:
3840

@@ -105,7 +107,7 @@ runtime 内置 `HookPointCapability` 作为唯一真源,定义每个点位是
105107
约束规则:
106108

107109
- `CanBlock=false` 的点位,hook 返回 `block` 会自动降级为观测结果,不中断主链。
108-
- `CanUpdateInput` 仅作为能力建模;当前阶段不开放输入改写通道
110+
- `CanUpdateInput` `user_prompt_submit` 点位已开放:command hook 可通过 stdout JSON 的 `update_input` 字段改写用户输入
109111
- `UserAllowed=false` 的点位拒绝 user/repo 挂载(配置 fail-fast)。
110112

111113
### trust gate
@@ -135,6 +137,159 @@ trust store 固定路径:
135137
- 绝对路径必须位于 workdir 内
136138
- symlink 路径会进行 realpath 校验,禁止绕过
137139

140+
## P6 command hooks
141+
142+
`kind=command` 允许 user/repo scope 通过外部可执行脚本参与 hook 链。
143+
144+
### stdin 协议
145+
146+
外部命令通过 stdin 接收单行 JSON:
147+
148+
```json
149+
{
150+
"payload_version": "1",
151+
"hook_id": "my-hook",
152+
"point": "before_tool_call",
153+
"run_id": "run_abc123",
154+
"session_id": "sess_abc123",
155+
"metadata": {
156+
"tool_name": "bash",
157+
"workdir": "/path/to/workspace"
158+
}
159+
}
160+
```
161+
162+
- `payload_version`:协议版本号,当前固定 `"1"`,变更 stdin 结构时递增
163+
- `hook_id`:hook 配置中的 `id`
164+
- `point`:触发点位名称
165+
- `metadata`:经白名单裁剪后的上下文字段(与 builtin/http hook 相同的 allowlist)
166+
167+
### stdout 协议
168+
169+
外部命令通过 stdout 返回单行 JSON:
170+
171+
```json
172+
{
173+
"status": "pass",
174+
"message": "optional message",
175+
"update_input": {"text": "rewritten prompt"},
176+
"annotations": ["note1", "note2"]
177+
}
178+
```
179+
180+
- `status`:必填,`pass` / `block` / `failed`
181+
- `message`:可选,进入 hook event 和 annotation buffer
182+
- `update_input`:仅 `CanUpdateInput=true` 的点位(当前仅 `user_prompt_submit`)允许;格式 `{"text": "..."}` 替换用户输入文本
183+
- `annotations`:字符串数组,进入 runtime annotation buffer
184+
185+
### stdout 退化模式
186+
187+
如果 stdout 不是合法 JSON,handler 退化为 exit code 模式:
188+
189+
- exit 0 → `pass`
190+
- exit 1 或 2 → `block`
191+
- 其他 → `failed`
192+
193+
原始 stdout 文本作为 `message`。此模式兼容简单脚本(如 `echo "ok"; exit 0`)。
194+
195+
### 执行模式
196+
197+
#### argv 模式(默认)
198+
199+
`params.command` 为字符串数组,直接 exec 不经 shell:
200+
201+
```yaml
202+
kind: command
203+
params:
204+
command:
205+
- python3
206+
- /path/to/hook.py
207+
```
208+
209+
#### shell 模式
210+
211+
`params.command` 为字符串且 `params.shell: true`,通过 `sh -c`(Unix)/ `powershell -Command`(Windows)执行:
212+
213+
```yaml
214+
kind: command
215+
params:
216+
command: "python3 /path/to/hook.py"
217+
shell: true
218+
```
219+
220+
单字符串 `params.command` 不设置 `params.shell: true` 会触发配置校验错误。
221+
222+
### 环境变量
223+
224+
命令进程仅注入以下环境变量,不继承宿主环境:
225+
226+
| 变量 | 值 |
227+
|------|------|
228+
| `NEOCODE_HOOK_HOOK_ID` | hook 的 `id` |
229+
| `NEOCODE_HOOK_POINT` | 触发点位(如 `before_tool_call`) |
230+
| `NEOCODE_HOOK_PAYLOAD_VERSION` | `"1"` |
231+
232+
Windows 额外注入 `SystemRoot`、`SystemDrive`、`USERPROFILE`(从宿主环境读取),以确保 TLS 证书加载和运行时基础功能正常工作。
233+
234+
### 执行约束
235+
236+
- workdir = 当前 run 的 workspace(`cmd.Dir = workdir`)
237+
- 超时 = hook 配置的 `timeout_sec`(默认 2s)
238+
- 并发限制 = executor 的 `max_in_flight`(默认 128)
239+
- repo scope command hook 受 trust gate 保护
240+
- stdout 大小限制 = 1 MiB;超出视为 `failed`
241+
242+
### stderr 处理
243+
244+
外部命令的 stderr 与 stdout 分离捕获。stderr 不会混入 `message` 字段,仅在命令执行失败(非零 exit code)且 stdout 无可用 message 时,stderr 内容才作为 fallback 追加到结果中。此设计确保 hook 协议输出(stdout JSON)不受调试输出(stderr)干扰。
245+
246+
### stdin 字段说明
247+
248+
- `run_id` / `session_id` 同时出现在 payload 顶层和 `metadata` 中。**顶层字段为权威来源**,`metadata` 中的同名字段为冗余副本(与 builtin/http hook 的 metadata allowlist 一致)。外部脚本应优先读取顶层字段。
249+
- `payload_version` 当前固定为 `"1"`,变更 stdin 结构时递增。
250+
251+
### update_input 与 block 交互
252+
253+
当 hook 返回 `status: "block"` 时,`update_input` 不会被应用。阻断优先于输入改写——hook 链在检测到 block 后立即终止,不进入 `applyCommandHookUpdateInput` 逻辑。
254+
255+
### 安全:exit code 优先于 JSON status
256+
257+
当命令以非零 exit code 退出时,stdout 中 JSON 声称的 `status` 字段被忽略。exit code 的映射优先:
258+
259+
- exit 1/2 → `block`
260+
- 其他非零 → `failed`
261+
262+
此规则防止恶意脚本通过 `{"status":"pass"}` 掩盖实际失败。JSON 中的 `message` 和 `annotations` 仍会被提取(如果 stdout 是合法 JSON)。
263+
264+
### 示例
265+
266+
#### Python
267+
268+
```python
269+
#!/usr/bin/env python3
270+
import json, sys
271+
272+
payload = json.loads(sys.stdin.readline())
273+
if payload["metadata"].get("tool_name") == "bash":
274+
json.dump({"status": "block", "message": "bash not allowed"}, sys.stdout)
275+
else:
276+
json.dump({"status": "pass"}, sys.stdout)
277+
print()
278+
```
279+
280+
#### Bash
281+
282+
```bash
283+
#!/bin/bash
284+
read -r line
285+
tool=$(echo "$line" | jq -r '.metadata.tool_name // empty')
286+
if [ "$tool" = "rm" ]; then
287+
echo '{"status":"block","message":"rm is blocked"}'
288+
else
289+
echo '{"status":"pass"}'
290+
fi
291+
```
292+
138293
## 可观测性
139294

140295
runtime 会透传 hooks 生命周期事件:

internal/config/runtime_hooks.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,8 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error {
286286
if normalizedMode != runtimeHookModeSync {
287287
return fmt.Errorf("mode %q is not supported for kind command (only sync)", c.Mode)
288288
}
289-
if strings.TrimSpace(readRuntimeHookParamString(c.Params, "command")) == "" {
290-
return fmt.Errorf("kind command requires params.command")
289+
if err := hooks.ValidateCommandParams(c.Params); err != nil {
290+
return err
291291
}
292292
case runtimeHookKindHTTP:
293293
if normalizedMode != runtimeHookModeObserve {

internal/config/runtime_hooks_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,88 @@ func TestRuntimeHooksConfigValidateAllowsCommand(t *testing.T) {
152152
Mode: runtimeHookModeSync,
153153
TimeoutSec: 2,
154154
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
155+
Params: map[string]any{"command": []any{"echo", "ok"}},
156+
},
157+
},
158+
}
159+
if err := cfg.Validate(); err != nil {
160+
t.Fatalf("Validate() error = %v", err)
161+
}
162+
}
163+
164+
func TestRuntimeHooksConfigValidateCommandShellMode(t *testing.T) {
165+
t.Parallel()
166+
167+
cfg := RuntimeHooksConfig{
168+
Enabled: boolPtr(true),
169+
UserHooksEnabled: boolPtr(true),
170+
DefaultTimeoutSec: 2,
171+
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
172+
Items: []RuntimeHookItemConfig{
173+
{
174+
ID: "cmd-shell",
175+
Point: string(hooks.HookPointAcceptGate),
176+
Scope: runtimeHookScopeUser,
177+
Kind: runtimeHookKindCommand,
178+
Mode: runtimeHookModeSync,
179+
TimeoutSec: 2,
180+
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
181+
Params: map[string]any{"command": "echo ok", "shell": true},
182+
},
183+
},
184+
}
185+
if err := cfg.Validate(); err != nil {
186+
t.Fatalf("Validate() error = %v", err)
187+
}
188+
}
189+
190+
func TestRuntimeHooksConfigValidateCommandStringWithoutShellRejected(t *testing.T) {
191+
t.Parallel()
192+
193+
cfg := RuntimeHooksConfig{
194+
Enabled: boolPtr(true),
195+
UserHooksEnabled: boolPtr(true),
196+
DefaultTimeoutSec: 2,
197+
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
198+
Items: []RuntimeHookItemConfig{
199+
{
200+
ID: "cmd-no-shell",
201+
Point: string(hooks.HookPointAcceptGate),
202+
Scope: runtimeHookScopeUser,
203+
Kind: runtimeHookKindCommand,
204+
Mode: runtimeHookModeSync,
205+
TimeoutSec: 2,
206+
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
155207
Params: map[string]any{"command": "echo ok"},
156208
},
157209
},
158210
}
211+
if err := cfg.Validate(); err == nil {
212+
t.Fatal("expected error for string command without shell=true")
213+
}
214+
}
215+
216+
func TestRuntimeHooksConfigValidateCommandArgvMode(t *testing.T) {
217+
t.Parallel()
218+
219+
cfg := RuntimeHooksConfig{
220+
Enabled: boolPtr(true),
221+
UserHooksEnabled: boolPtr(true),
222+
DefaultTimeoutSec: 2,
223+
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
224+
Items: []RuntimeHookItemConfig{
225+
{
226+
ID: "cmd-argv",
227+
Point: string(hooks.HookPointAcceptGate),
228+
Scope: runtimeHookScopeUser,
229+
Kind: runtimeHookKindCommand,
230+
Mode: runtimeHookModeSync,
231+
TimeoutSec: 2,
232+
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
233+
Params: map[string]any{"command": []string{"echo", "hello"}},
234+
},
235+
},
236+
}
159237
if err := cfg.Validate(); err != nil {
160238
t.Fatalf("Validate() error = %v", err)
161239
}

0 commit comments

Comments
 (0)