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
161 changes: 158 additions & 3 deletions docs/runtime-hooks-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
- P4:生命周期点位扩展(permission/session/compact/subagent)+ 点位能力矩阵
- P5:internal hooks 支持 `async/async_rewake` + run 内存通知队列(ephemeral 注入)
- P6-lite:user `http/observe` hooks(仅观测回调)
- P6:user/repo `command` hooks(stdin/stdout JSON 协议)

当前未实现能力:

- command/prompt/agent hooks(P6)
- prompt/agent hooks(P6)

## P2 user hooks 边界

Expand All @@ -32,7 +33,8 @@ P2 仅支持:
- `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`),避免误配为公网外发
- external kinds 中 `command/prompt/agent` 在 P6-lite 阶段显式拒绝,不会半生效
- `kind=command + mode=sync`:允许执行外部命令,通过 stdin/stdout JSON 协议通信(详见下方 P6 章节)
- external kinds 中 `prompt/agent` 仍显式拒绝

当前(P3)明确不支持:

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

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

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

## P6 command hooks

`kind=command` 允许 user/repo scope 通过外部可执行脚本参与 hook 链。

### stdin 协议

外部命令通过 stdin 接收单行 JSON:

```json
{
"payload_version": "1",
"hook_id": "my-hook",
"point": "before_tool_call",
"run_id": "run_abc123",
"session_id": "sess_abc123",
"metadata": {
"tool_name": "bash",
"workdir": "/path/to/workspace"
}
}
```

- `payload_version`:协议版本号,当前固定 `"1"`,变更 stdin 结构时递增
- `hook_id`:hook 配置中的 `id`
- `point`:触发点位名称
- `metadata`:经白名单裁剪后的上下文字段(与 builtin/http hook 相同的 allowlist)

### stdout 协议

外部命令通过 stdout 返回单行 JSON:

```json
{
"status": "pass",
"message": "optional message",
"update_input": {"text": "rewritten prompt"},
"annotations": ["note1", "note2"]
}
```

- `status`:必填,`pass` / `block` / `failed`
- `message`:可选,进入 hook event 和 annotation buffer
- `update_input`:仅 `CanUpdateInput=true` 的点位(当前仅 `user_prompt_submit`)允许;格式 `{"text": "..."}` 替换用户输入文本
- `annotations`:字符串数组,进入 runtime annotation buffer

### stdout 退化模式

如果 stdout 不是合法 JSON,handler 退化为 exit code 模式:

- exit 0 → `pass`
- exit 1 或 2 → `block`
- 其他 → `failed`

原始 stdout 文本作为 `message`。此模式兼容简单脚本(如 `echo "ok"; exit 0`)。

### 执行模式

#### argv 模式(默认)

`params.command` 为字符串数组,直接 exec 不经 shell:

```yaml
kind: command
params:
command:
- python3
- /path/to/hook.py
```

#### shell 模式

`params.command` 为字符串且 `params.shell: true`,通过 `sh -c`(Unix)/ `powershell -Command`(Windows)执行:

```yaml
kind: command
params:
command: "python3 /path/to/hook.py"
shell: true
```

单字符串 `params.command` 不设置 `params.shell: true` 会触发配置校验错误。

### 环境变量

命令进程仅注入以下环境变量,不继承宿主环境:

| 变量 | 值 |
|------|------|
| `NEOCODE_HOOK_HOOK_ID` | hook 的 `id` |
| `NEOCODE_HOOK_POINT` | 触发点位(如 `before_tool_call`) |
| `NEOCODE_HOOK_PAYLOAD_VERSION` | `"1"` |

Windows 额外注入 `SystemRoot`、`SystemDrive`、`USERPROFILE`(从宿主环境读取),以确保 TLS 证书加载和运行时基础功能正常工作。

### 执行约束

- workdir = 当前 run 的 workspace(`cmd.Dir = workdir`)
- 超时 = hook 配置的 `timeout_sec`(默认 2s)
- 并发限制 = executor 的 `max_in_flight`(默认 128)
- repo scope command hook 受 trust gate 保护
- stdout 大小限制 = 1 MiB;超出视为 `failed`

### stderr 处理

外部命令的 stderr 与 stdout 分离捕获。stderr 不会混入 `message` 字段,仅在命令执行失败(非零 exit code)且 stdout 无可用 message 时,stderr 内容才作为 fallback 追加到结果中。此设计确保 hook 协议输出(stdout JSON)不受调试输出(stderr)干扰。

### stdin 字段说明

- `run_id` / `session_id` 同时出现在 payload 顶层和 `metadata` 中。**顶层字段为权威来源**,`metadata` 中的同名字段为冗余副本(与 builtin/http hook 的 metadata allowlist 一致)。外部脚本应优先读取顶层字段。
- `payload_version` 当前固定为 `"1"`,变更 stdin 结构时递增。

### update_input 与 block 交互

当 hook 返回 `status: "block"` 时,`update_input` 不会被应用。阻断优先于输入改写——hook 链在检测到 block 后立即终止,不进入 `applyCommandHookUpdateInput` 逻辑。

### 安全:exit code 优先于 JSON status

当命令以非零 exit code 退出时,stdout 中 JSON 声称的 `status` 字段被忽略。exit code 的映射优先:

- exit 1/2 → `block`
- 其他非零 → `failed`

此规则防止恶意脚本通过 `{"status":"pass"}` 掩盖实际失败。JSON 中的 `message` 和 `annotations` 仍会被提取(如果 stdout 是合法 JSON)。

### 示例

#### Python

```python
#!/usr/bin/env python3
import json, sys

payload = json.loads(sys.stdin.readline())
if payload["metadata"].get("tool_name") == "bash":
json.dump({"status": "block", "message": "bash not allowed"}, sys.stdout)
else:
json.dump({"status": "pass"}, sys.stdout)
print()
```

#### Bash

```bash
#!/bin/bash
read -r line
tool=$(echo "$line" | jq -r '.metadata.tool_name // empty')
if [ "$tool" = "rm" ]; then
echo '{"status":"block","message":"rm is blocked"}'
else
echo '{"status":"pass"}'
fi
```

## 可观测性

runtime 会透传 hooks 生命周期事件:
Expand Down
4 changes: 2 additions & 2 deletions internal/config/runtime_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error {
if normalizedMode != runtimeHookModeSync {
return fmt.Errorf("mode %q is not supported for kind command (only sync)", c.Mode)
}
if strings.TrimSpace(readRuntimeHookParamString(c.Params, "command")) == "" {
return fmt.Errorf("kind command requires params.command")
if err := hooks.ValidateCommandParams(c.Params); err != nil {
return err
}
case runtimeHookKindHTTP:
if normalizedMode != runtimeHookModeObserve {
Expand Down
78 changes: 78 additions & 0 deletions internal/config/runtime_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,88 @@ func TestRuntimeHooksConfigValidateAllowsCommand(t *testing.T) {
Mode: runtimeHookModeSync,
TimeoutSec: 2,
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
Params: map[string]any{"command": []any{"echo", "ok"}},
},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}

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

cfg := RuntimeHooksConfig{
Enabled: boolPtr(true),
UserHooksEnabled: boolPtr(true),
DefaultTimeoutSec: 2,
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
Items: []RuntimeHookItemConfig{
{
ID: "cmd-shell",
Point: string(hooks.HookPointAcceptGate),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindCommand,
Mode: runtimeHookModeSync,
TimeoutSec: 2,
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
Params: map[string]any{"command": "echo ok", "shell": true},
},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}

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

cfg := RuntimeHooksConfig{
Enabled: boolPtr(true),
UserHooksEnabled: boolPtr(true),
DefaultTimeoutSec: 2,
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
Items: []RuntimeHookItemConfig{
{
ID: "cmd-no-shell",
Point: string(hooks.HookPointAcceptGate),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindCommand,
Mode: runtimeHookModeSync,
TimeoutSec: 2,
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
Params: map[string]any{"command": "echo ok"},
},
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("expected error for string command without shell=true")
}
}

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

cfg := RuntimeHooksConfig{
Enabled: boolPtr(true),
UserHooksEnabled: boolPtr(true),
DefaultTimeoutSec: 2,
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
Items: []RuntimeHookItemConfig{
{
ID: "cmd-argv",
Point: string(hooks.HookPointAcceptGate),
Scope: runtimeHookScopeUser,
Kind: runtimeHookKindCommand,
Mode: runtimeHookModeSync,
TimeoutSec: 2,
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
Params: map[string]any{"command": []string{"echo", "hello"}},
},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
Expand Down
Loading
Loading