diff --git a/agent-eval/suite/testcases.json b/agent-eval/suite/testcases.json index 63aa4fa..78b7a54 100644 --- a/agent-eval/suite/testcases.json +++ b/agent-eval/suite/testcases.json @@ -961,6 +961,52 @@ "generate": true } } + }, + { + "id": "hook_pretooluse_deny", + "title": "PreToolUse hook denies a write and blocks the file", + "category": "hook", + "tier": "hook", + "prompt": "Create a file named forbidden.txt containing the text nope. If a tool call is blocked by policy, stop and briefly explain instead of working around it.", + "fixtures": {}, + "home_fixtures": { + ".jcode/hooks.json": "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"write\",\"hooks\":[{\"type\":\"command\",\"command\":\"echo blocked-by-policy >&2; exit 2\"}]}]}}" + }, + "timeout": 150, + "expect_tool_use": true, + "oracles": [ + { + "type": "file_absent", + "path": "forbidden.txt" + }, + { + "type": "bounded_tool_calls", + "max": 8 + } + ] + }, + { + "id": "hook_posttooluse_side_effect", + "title": "PostToolUse hook runs after a successful write", + "category": "hook", + "tier": "hook", + "prompt": "Create a file named note.txt with the exact contents hi and no trailing newline. Then you are done.", + "fixtures": {}, + "home_fixtures": { + ".jcode/hooks.json": "{\"hooks\":{\"PostToolUse\":[{\"matcher\":\"write\",\"hooks\":[{\"type\":\"command\",\"command\":\"echo fired > hook-ran.txt\"}]}]}}" + }, + "timeout": 150, + "expect_tool_use": true, + "oracles": [ + { + "type": "file_exists", + "path": "note.txt" + }, + { + "type": "file_exists", + "path": "hook-ran.txt" + } + ] } ] -} +} \ No newline at end of file diff --git a/internal-doc/hooks-design.md b/internal-doc/hooks-design.md new file mode 100644 index 0000000..ff791b9 --- /dev/null +++ b/internal-doc/hooks-design.md @@ -0,0 +1,493 @@ +# jcode Hooks 设计文档 + +> 状态:设计草案(调研阶段产出) +> 目标:为 jcode 增加一套「在 agent 执行的关键节点触发用户可配置动作」的 hook 机制, +> 覆盖 TUI / Web / ACP 三个 transport,且不侵入现有工具与审批核心逻辑。 + +--- + +## 0. 结论先行(TL;DR) + +- **产品形态**:采用 **Claude Code / Qoder 同款的「外部命令 hook」范式**——配置驱动、语言无关的脚本、 + JSON over stdin/stdout、退出码控制、matcher 过滤。jcode 的用户群与 Claude Code 高度重叠,沿用同一套 + 心智模型和 schema 迁移成本最低。 +- **内部架构**:借鉴 **Copilot SDK / codex** 的做法,把所有 hook 收敛到一个 **transport 无关的 + `internal/hooks.Dispatcher`**,而不是塞进某个 UI handler。这样 TUI/Web/ACP 自动共享同一套 hook, + 未来还能扩展「进程内 Go 回调」「HTTP webhook」等 hook 类型。 +- **挂载点**(全部已在代码中核对,见 §3): + - 工具前后 → `internal/agent` 新增一个 hook 中间件,包在 `approvalMiddleware` 外层。 + - 审批 → 复用 `ApprovalState`(PreToolUse 的 allow/deny 直接影响审批)。 + - 会话/turn 生命周期 → `runner.Run` 与 `command/interactive.go:handlePrompt`。 + - 压缩 → `agent/compaction.go` 已有的 `onCompact` 回调点。 + - 会话文件 → `session.Recorder` 的创建与 `Close`。 +- **v1 事件集**(7 个,够用且与生态对齐): + `SessionStart` · `UserPromptSubmit` · `PreToolUse` · `PostToolUse` · `PostToolUseFailure` · `PreCompact` · `Stop`。 +- **安全**:hook = 执行任意命令,必须有信任门槛。v1 采用 **codex 式 trust-on-first-use(内容 hash 信任)** + + 「项目级 hook 默认不信任、需显式确认」。 + +--- + +## 1. 三方参考对比 + +| 维度 | Claude Code / Qoder | Copilot SDK | codex | +|---|---|---|---| +| 范式 | 外部命令(脚本) | 进程内 Go/TS 回调 | 外部命令(脚本) | +| 面向 | 终端最终用户 | SDK 嵌入者/开发者 | 终端最终用户 | +| 事件数 | 5(`UserPromptSubmit`/`PreToolUse`/`PostToolUse`/`PostToolUseFailure`/`Stop`) | 7(`onSessionStart`…`onErrorOccurred`) | 10(含 `PermissionRequest`/`Pre/PostCompact`/`SubagentStart/Stop`/`SessionStart`) | +| 配置 | `settings.json` 三层合并(user/project/local) | `CreateSession(hooks:{...})` 代码注册 | `config.toml` + `hooks.json`,8 层优先级 | +| 输入 | stdin JSON | 函数入参结构体 | stdin JSON | +| 输出 | exit code(0 放行 / 2 阻断)+ stdout JSON | 返回结构体(allow/deny/ask、改写 result/prompt) | exit code + stdout JSON(`continue`/`decision`/`updatedInput`/`additionalContext`) | +| matcher | 精确 / `A\|B` / 正则 | 无(回调内自行判断) | 正则 / `*` / 别名 | +| 阻断/改写 | PreToolUse 可 deny + `updatedInput` + `additionalContext` | onPreToolUse 返回 allow/deny/ask;onPostToolUse 改 result | 同 Claude Code,另有 `PermissionRequest` 专门自动审批 | +| 信任模型 | 加载时展示、用户审阅 | 无(都是自己写的代码) | 内容 hash 信任 + 管理员 `allow_managed_hooks_only` | + +**取舍**: +- 对外 schema **对齐 Claude Code**(`hook_event_name`/`tool_name`/`tool_input`/`tool_response`/`permissionDecision`/`updatedInput`/`additionalContext` 等字段名照抄),让熟悉 Claude Code 的用户零学习成本。 +- 对内实现**对齐 codex**(discovery → dispatcher → command_runner → output_parser 四段式 + 并发执行 + 信任 hash)。 +- 事件集取二者交集 + jcode 真实存在的节点,v1 收敛为 7 个,**不引入独立的 `PermissionRequest`**(用 PreToolUse 的 `permissionDecision` 覆盖),也**暂不做 `SubagentStart/Stop`**(留待 team/subagent 稳定后)。 + +--- + +## 2. jcode 现状(已核对的事实) + +| 事项 | 位置 | 说明 | +|---|---|---| +| Agent 单 turn 顶层 | `internal/runner/runner.go:24` `Run()` | 负责 tracing、token、todo/goal 续行、`OnAgentStart/OnAgentDone` | +| turn 内 LLM+工具迭代 | `internal/runner/runner.go:157` `runInner()` | 流式事件 → handler | +| 用户输入入口 | `internal/command/interactive.go:369` `handlePrompt()` | `RecordUser` → `runner.Run` | +| Agent 工厂 & 中间件装配 | `internal/agent/agent.go:25` `NewAgent()` | 链序(外→内):`middlewares` → `handlers` → `approvalMiddleware` → `memory.UsageMiddleware` | +| 审批+安全中间件 | `internal/agent/middleware.go:30` `WrapInvokableToolCall` | 工具执行的唯一咽喉:审批 gate → `endpoint()` → 错误兜底 | +| 审批函数类型 | `internal/agent/agent.go:17` | `type ApprovalFunc func(ctx, toolName, toolArgs string) (bool, error)` | +| 审批决策权威 | `internal/runner/approval.go:238` `decide()` / `:359` `RequestApproval()` | 三档:AutoApprove / Prompt / PromptExternal | +| 事件总线接口 | `internal/handler/handler.go:19` `AgentEventHandler` | `OnAgentStart/OnAgentDone/OnToolCall/OnToolResult/OnTokenUpdate/RequestApproval` | +| 压缩回调 | `internal/agent/compaction.go:166` `NewCompactionMiddleware(..., onCompact)` | 已有 `onCompact(savedTokens int)` 回调 | +| 会话记录 | `internal/session/session.go:165` `NewRecorder()` / `Close()` | JSONL transcript,`transcript_path` 可得 | +| 已有 callback 惯例 | `GoalStore.OnUpdate`、budget `onWarn`、compaction `onCompact` | jcode 已大量用「函数回调」做扩展,hook dispatcher 沿用同风格 | +| 配置结构 | `internal/config/config.go:249` `Config`(扁平 JSON,`~/.jcode/config.json`) | 加一个 `Hooks *HooksConfig` 字段即可 | +| 项目级目录 | `.jcode/`(已托管项目级 skills) | 可托管 `.jcode/hooks.json` | + +**关键洞察**:jcode 的工具审批与执行**已经全部收敛在 `approvalMiddleware` 一个点**,且审批是 transport 无关的(走 `ApprovalFunc`,不依赖具体 UI)。这意味着 hook 挂在中间件层,就天然对 TUI/Web/ACP 三端生效——**不要**把 hook 塞进 `AgentEventHandler`(那是 per-transport 的展示层)。 + +--- + +## 3. 挂载点设计 + +### 3.1 工具类事件:新增 `hookMiddleware`(`internal/agent/hook_middleware.go`) + +放在中间件链里 **`approvalMiddleware` 的外层**(即作为 `handlers` 的最后一个,或在 `agent.go` 里 approval 之前 append), +使其包住「审批 + 执行」: + +```text +tracing → budget/compaction/reminder → [hookMiddleware] → approvalMiddleware → memory.UsageMiddleware → tool + │ PreToolUse │ 审批 gate + endpoint() + └─ PostToolUse/Failure ───┘ +``` + +`WrapInvokableToolCall` 骨架(签名与 `middleware.go:30` 一致): + +```go +func (m *hookMiddleware) WrapInvokableToolCall( + ctx context.Context, endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext, +) (adk.InvokableToolCallEndpoint, error) { + return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { + // ---- PreToolUse ---- + dec := m.disp.Fire(ctx, hooks.PreToolUse, hooks.Payload{ + ToolName: tCtx.Name, ToolInput: json.RawMessage(args), + }) + switch dec.Permission { + case hooks.Deny: + // 阻断:不执行,把理由回给模型(复用 approval 的“被拒绝”文案) + return denyMessage(dec.Reason), nil + case hooks.Allow: + // 预授权:让内层 approvalMiddleware 跳过用户弹窗 + ctx = approval.WithPreApproved(ctx) + } + if dec.UpdatedInput != nil { // updatedInput:改写工具参数 + args = string(dec.UpdatedInput) + } + if dec.AdditionalContext != "" { + // 作为工具结果前缀注入模型(或走 reminder 通道) + } + + result, err := endpoint(ctx, args, opts...) // 审批 + 执行都在这里 + + // ---- PostToolUse / PostToolUseFailure ---- + ev := hooks.PostToolUse + if err != nil || looksLikeToolError(result) { + ev = hooks.PostToolUseFailure + } + post := m.disp.Fire(ctx, ev, hooks.Payload{ + ToolName: tCtx.Name, ToolInput: json.RawMessage(args), + ToolResponse: result, + }) + if post.ModifiedResult != nil { // 允许改写/脱敏结果 + result = *post.ModifiedResult + } + return result, err + }, nil +} +``` + +**PreToolUse 的 `Allow` 如何跳过弹窗**:在 `approvalMiddleware`(`middleware.go`)里加一行短路—— +`if approval.IsPreApproved(ctx) { /* 跳过 approvalFunc */ }`。这是唯一需要动到既有中间件的地方,改动极小。 + +> 备选方案:把 PreToolUse 的自动 allow/deny 直接**接进 `ApprovalState.decide()`**(`approval.go:238`), +> 让审批只有一个权威、PostToolUse 仍留在中间件。二者取一即可;推荐上面的 ctx-flag 方案,改动更集中。 + +### 3.2 会话 / turn 生命周期 + +| 事件 | 触发点 | 备注 | +|---|---|---| +| `SessionStart` | `session/session.go` `NewRecorder()`/`ensureFile()` 首帧落盘时;或 `interactive.go:RunInteractive` 启动处 | payload 含 `session_id`/`cwd`/`model`;`additionalContext` 可注入系统提示 | +| `UserPromptSubmit` | `interactive.go:369 handlePrompt` 里 `RecordUser` 之后、`runner.Run` 之前 | 可 deny(拦下这次提交)或 `additionalContext`(追加上下文);对齐 Claude Code | +| `Stop` | `runner.Run` 返回后 / `OnAgentDone` 处;或 `handlePrompt` 尾部 | 可 deny → 强制 agent 续跑(质量门禁,如「跑完测试再收尾」)。**必须带 `stop_hook_active` 防死循环** | +| `PreCompact` / `PostCompact` | `agent/compaction.go:166` `onCompact` 回调前后 | 已有回调点,直接扩展 | + +`UserPromptSubmit` / `Stop` 走 `runner`/`interactive` 层,同样 transport 无关。 + +### 3.3 事件—代码对照总表 + +| Hook 事件 | jcode 挂载文件:符号 | 可阻断 | 可改写 | payload 关键字段 | +|---|---|---|---|---| +| `SessionStart` | `session/session.go NewRecorder` | 否 | `additionalContext` | session_id, cwd, model, source | +| `UserPromptSubmit` | `command/interactive.go:369 handlePrompt` | 是 | `additionalContext` | prompt | +| `PreToolUse` | `agent/hook_middleware.go`(新) | 是 | `updatedInput`,`additionalContext` | tool_name, tool_input | +| `PostToolUse` | `agent/hook_middleware.go`(新) | 否 | `modifiedResult` | tool_name, tool_input, tool_response | +| `PostToolUseFailure` | `agent/hook_middleware.go`(新) | 否 | `additionalContext` | tool_name, tool_input, tool_response(err) | +| `PreCompact` / `PostCompact` | `agent/compaction.go:166` | 否 | — | trigger, saved_tokens | +| `Stop` | `runner/runner.go:Run` 尾(统一续跑循环,见 §3.4) | 是 | — | stop_hook_active | + +### 3.4 统一续跑管线(Stop hook × todo/goal,参考 codex) + +**codex 的关键教训:它根本没有「两套续跑」。** codex 一个 turn 只有一个内层采样循环 +(`turn.rs:225`),模型自然停下时在**唯一一个决策点**跑 Stop hook(`turn.rs:373`);hook 若 block, +就把 continuation 理由拼成一条 user 消息注入历史、置 `stop_hook_active=true`、`continue` 同一个循环 +(`turn.rs:380-403`)。codex **没有独立的 todo/goal 自动续跑**,也**没有硬性次数上限**——只靠三样收敛: +hook 脚本自己看 `stop_hook_active` 决定何时放行、外层 `input_queue` 是否还有 pending、以及 +`CancellationToken`(用户取消一票否决 → `TurnAborted`)。 + +**映射回 jcode**:现在 `runner.Run` 是 `todoLoop`(上限 3)+ `goalLoop`(上限 25)两个**顺序独立的 +`for`**;直接再叠一个 Stop hook 会变成三套各自为政、无统一上限的强制续跑。按 codex 的形状,合并成 +**一个续跑循环 + 一个决策聚合点**: + +```text +runInner() // 代理采样到 LLM 不再调工具 +for { // 单一续跑循环 + if ctx.Done() { break } // 用户取消一票否决(保留现有语义) + if budget exhausted { break } // 单一 umbrella 上限(见差异②) + + var reasons []string + // ① 内建 guard 先——它们想续跑,就说明代理还没“真的要停” + if todoStore.HasIncomplete() { reasons = append(reasons, todoReminder) } + if goalStore.IsActive() && goalCont!="" { reasons = append(reasons, goalCont) } + + // ② 内建 guard 都安静了,才 fire Stop hook(否则“插太早”,对着半成品跑) + if len(reasons) == 0 { + dec := disp.Fire(ctx, hooks.Stop, hooks.Payload{StopHookActive: stopHookActive}) + if dec.Block { reasons = append(reasons, dec.Reason); stopHookActive = true } + } + + if len(reasons) == 0 { break } // 三方都不续 → 真收尾 + inject(reasons); runInner() // 注入成一条 user 消息,再跑一轮 +} +h.OnAgentDone(nil) +``` + +**与 codex 三处有意的差异**: + +1. **顺序**:内建 guard(todo/goal)排在 Stop hook **前**。Stop 语义是「代理真要停了」,jcode 内建 guard + 还想续跑就说明没到那步,此时 fire Stop hook 会对着半成品跑。每轮先清内建 guard,清空了才问 Stop hook。 +2. **保留 umbrella 硬上限**(**jcode 必须与 codex 不同处**):codex 敢不设上限,是因为它的续跑**永远由脚本 + 显式驱动**;jcode 的 todo/goal 是**自动**续跑、无脚本兜底,一个永远证明不了完成的 goal 会无限滚。故必须 + 把现有的 `maxGoalContinuations=25` 提升为**整个续跑循环共享的一个 budget**(todo/goal/hook 共用一个计数), + 而非各自为政的 3 与 25。 +3. **`stop_hook_active` 跨轮携带**:Stop hook block 一次后置位,下一轮 fire 时带 `StopHookActive:true` 进 + payload,脚本据此放行(Qoder / Claude Code / codex 同款防自锁)。 + +**净效果**:三套「强制续跑」合成一条管线——**一个上限、一处取消检查、一个决策聚合点**——正是 codex 的形状, +只多保留了 jcode 的内建 guard 与一道机器上限做兜底。 + +--- + +## 4. 配置格式 + +### 4.1 文件位置与分层(**已定:方案 B — 独立 hooks.json 三层**) + +- ~~方案 A:塞进 `~/.jcode/config.json` 的 `hooks` 键~~ —— 已否,缺项目级 / 本地层。 +- **方案 B(已采纳)**:独立 `hooks` 配置,三层合并(对齐 Claude Code / Qoder,也贴合 jcode 已有的 `.jcode/` 项目目录): + 1. `~/.jcode/hooks.json` —— 用户级(最低优先级,默认加载) + 2. `.jcode/hooks.json` —— 项目级(可随 git 分享) + 3. `.jcode/hooks.local.json` —— 项目本地(`.gitignore`,最高优先级) + + 合并策略:同事件下的 hook 组**追加**(不覆盖),保证项目和用户 hook 都能跑。 + +### 4.2 Schema(对齐 Claude Code) + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|run_shell", + "hooks": [ + { "type": "command", "command": "~/.jcode/hooks/guard.sh", "timeout": 10 } + ] + } + ], + "PostToolUse": [ + { + "matcher": "write_file|edit_file", + "hooks": [ + { "type": "command", "command": "gofmt -w \"$JCODE_TOOL_FILE\"" } + ] + } + ], + "Stop": [ + { "hooks": [ { "type": "command", "command": "~/.jcode/hooks/test-gate.sh" } ] } + ] + } +} +``` + +Go 结构(`internal/config/config.go` 新增,挂到 `Config`): + +```go +type Config struct { + // ...既有字段... + Hooks *HooksConfig `json:"hooks,omitempty"` +} + +type HooksConfig struct { + // event name → 若干 matcher 组 + Events map[string][]HookGroup `json:"-"` // 反序列化时按事件名铺平 +} + +type HookGroup struct { + Matcher string `json:"matcher,omitempty"` // 空/“*”=全匹配;支持 A|B;支持正则 + Hooks []HookSpec `json:"hooks"` +} + +type HookSpec struct { + Type string `json:"type"` // v1 仅 "command" + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` // 秒,默认 60 + Async bool `json:"async,omitempty"` // 非阻断事件可 fire-and-forget +} +``` + +### 4.3 matcher 语义(**已实现:精确优先**) + +matcher 按 `|` 拆分,**每个片段独立判定**: + +- 省略 / `*` → 匹配全部工具 +- **无正则元字符的片段 → 精确全等**(`"write"` 只命中 `write`,**不会**误匹配高频的 `todowrite`/`overwrite`) +- 含元字符(`.^$*+?()[]{}\`)→ 当正则(`"mcp__.*"`、`"^execute$"`) +- 竖线 → 片段并集(`"write|edit"` = 精确匹配 write 或 edit) +- **工具别名表**:真实工具名 `execute`/`write`/`edit`/`read`/`grep`/`glob` ↔ Claude Code 名 `Bash`/`Write`/`Edit`/`Read`/`Grep`/`Glob`,让抄来的配置可用。 + +> 「精确优先」是审查发现 H3 的修复:unanchored 正则会让 `"write"` 静默命中 `todowrite`(每次 todo 更新都触发),是明确 footgun。 + +--- + +## 5. 执行协议(对齐 Claude Code / codex) + +### 5.1 输入(stdin,JSON) + +```json +{ + "session_id": "uuid", + "transcript_path": "/Users/.../.jcode/sessions/xxx.jsonl", + "cwd": "/Users/jack/workpath/jjj/jcode", + "hook_event_name": "PreToolUse", + "tool_name": "write_file", + "tool_input": { "path": "a.go", "content": "..." }, + "tool_response": "…", // 仅 PostToolUse* + "prompt": "…", // 仅 UserPromptSubmit + "stop_hook_active": false // 仅 Stop +} +``` + +同时注入环境变量,方便脚本免解析:`JCODE_SESSION_ID`、`JCODE_TOOL_NAME`、`JCODE_CWD`、`JCODE_TRANSCRIPT_PATH`、`JCODE_HOOK_EVENT`。 + +### 5.2 输出(exit code + stdout) + +| exit code | 行为 | +|---|---| +| `0` | 放行;若 stdout 有 JSON 则解析 `hookSpecificOutput` | +| `2` | 阻断(仅可阻断事件);stderr 注入对话反馈给模型 | +| 其它 | 非阻断错误;stderr 展示给用户,但**不**中断 agent | + +stdout 结构化输出(exit 0): + +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow|deny|ask", + "permissionDecisionReason": "…", + "updatedInput": { "...": "改写后的 tool_input" }, + "additionalContext": "注入给模型的额外上下文", + "modifiedResult": "PostToolUse 改写后的结果" + } +} +``` + +### 5.3 fail-safe(对齐 Qoder) + +- 超时(默认 60s,Qoder 30s)→ 当作放行,不阻断。 +- 未知 exit code → 非致命,展示不阻断。 +- hook 崩溃 → 记录 warning,agent 继续(`HookResult::FailedContinue` 语义)。 +- `Stop` hook **必须**在 `stop_hook_active==true` 时 exit 0,否则死循环。 + +--- + +## 6. 内部架构:`internal/hooks/` + +对齐 codex 的四段式,但用 Go 惯用法: + +```text +internal/hooks/ +├── config.go // HooksConfig 解析 + 三层合并 + 别名表 +├── dispatcher.go // Fire(ctx, event, payload) → Decision;matcher 选择 + 并发执行 + 结果折叠 +├── runner.go // command_runner:起子进程、stdin 喂 JSON、超时、捕获 stdout/stderr/exit +├── parse.go // output_parser:解析 exit code + hookSpecificOutput +├── trust.go // 内容 hash 信任(trust-on-first-use) +└── types.go // Payload / Decision / HookSpec +``` + +`Dispatcher` 单例在启动时构建(`interactive.go` / web engine / acp server 都注入同一个), +经由 `context` 或依赖注入传到中间件与 runner。**决策折叠规则**(多个 hook 命中同一事件时): +任一 `deny` 即最终 deny(保守);`updatedInput`/`modifiedResult` 按配置顺序链式套用; +`additionalContext` 拼接。 + +**为什么不用 Copilot SDK 的纯进程内回调**:jcode 的 hook 是**给最终用户配的**(不是给嵌入者写代码), +外部命令才是对的产品面。但架构上 `Dispatcher.Fire` 的返回类型 `Decision` 与 hook `type` 解耦—— +未来加 `"type": "webhook"` 或 `"type": "builtin"`(进程内 Go 回调)只是多一个 runner 实现,事件与挂载点不变。 + +--- + +## 7. 安全与信任 + +hook 会执行任意命令,是明确的攻击面(尤其项目级 `.jcode/hooks.json` 可能来自不可信仓库)。 + +> **v1 已落地的门控**:hook 命令一旦其事件触发即为任意代码执行——`SessionStart` 甚至在开会话瞬间就跑, +> `PreToolUse` 的 `permissionDecision=allow` 还能静默绕过审批。因此 `hooks.Load(configDir, workDir, trustProject)` +> **默认只加载用户级 `~/.jcode/hooks.json`**;项目级 `.jcode/hooks.json` / `.jcode/hooks.local.json` 仅在 +> `trustProject==true` 时加载(当前由环境变量 `JCODE_HOOKS_TRUST_PROJECT=1` 显式开启)。这是 trust-on-first-use +> 落地前的临时闸门,杜绝「clone 恶意仓库即被 getshell / 静默提权」。 + +后续完整信任模型: + +1. **trust-on-first-use(codex 式)**:首次发现某 hook(按 `source_path + command` 内容算 sha256)时标记 + `untrusted`,在 TUI/Web 提示用户审阅并确认;确认后把 `trusted_hash` 写入 `~/.jcode/hooks-state.json`。 + 内容变更 → 重新 untrusted。 +2. **来源分级**:`~/.jcode/` 用户级默认信任(是用户自己机器上的配置);`.jcode/*` 项目级默认**不信任**, + 需显式确认——防止 clone 一个仓库就被其 hook 提权。 +3. **可观测**:每次 hook 运行发 `HookStarted`/`HookCompleted` 事件(对齐 codex `HookRunSummary`), + 在 TUI 状态行/Web 面板可见,stdout/stderr 落 transcript。 +4. **企业锁**(可选,后续):`allow_managed_hooks_only` 之类开关,禁用用户/项目 hook。 +5. `/hooks` slash 命令 + Web 管理面板:列出、启用/禁用、查看信任状态(复用 MCP/skills 已有的管理 UI 模式)。 + +--- + +## 8. 分期实施计划 + +**Phase 1 — 骨架 + 工具类 hook(MVP)** +- `internal/hooks/`:config 解析、dispatcher、command runner、output parser。 +- `Config.Hooks` 字段 + 方案 B 的三层加载。 +- `agent/hook_middleware.go`:`PreToolUse` / `PostToolUse` / `PostToolUseFailure`。 +- `approvalMiddleware` 加 `IsPreApproved(ctx)` 短路。 +- 工具别名表。 +- 单测:deny 阻断、updatedInput 改参、modifiedResult 改结果、超时放行。 + +**Phase 2 — 生命周期 hook** +- `UserPromptSubmit`(interactive)、`Stop`(runner,含 `stop_hook_active` 防死循环)、 + `SessionStart`(session)、`PreCompact/PostCompact`(compaction)。 + +**Phase 3 — 信任与可观测** +- trust-on-first-use + `hooks-state.json`;`HookStarted/Completed` 事件;transcript 落盘。 +- `/hooks` 管理命令 + Web 面板;ACP 侧事件透出。 + +**Phase 4 — 扩展 hook 类型(可选)** +- `"type": "webhook"`(HTTP)/ `"type": "builtin"`(进程内 Go 回调,给 automation/team 复用)。 +- `SubagentStart/Stop`(配合 team)。 + +--- + +## 9. 开放问题(需拍板) + +1. ~~配置放哪~~ **已定:方案 B(独立 `hooks.json` 三层,见 §4.1)**。 +2. **是否引入独立 `PermissionRequest` 事件**(codex 有)?v1 建议不引入,用 `PreToolUse.permissionDecision` 覆盖。 +3. ~~`Stop` hook 与 todo/goal 续行如何叠加~~ **已定:合并成单一续跑管线(§3.4,参考 codex)**。 + 实现时仅需敲定 umbrella budget 的具体数值(沿用 25,或按 turn 复杂度动态)。 +4. **别名表维护成本**:jcode 工具名与 Claude Code 不完全一致,别名表要不要做成配置可覆盖? +5. **Web/ACP 的信任确认 UX**:非交互式(automation/cron)场景下 untrusted hook 怎么处理——跳过并告警,还是需预先信任? + +--- + +## 10. 测试方案(实现前设计) + +**沙箱现实**:jcode 的 `agent-eval/` e2e 用**真实 LLM**(无 mock model)+ 隔离 HOME + 决定论 oracle +(`agent-eval/suite/verify.py` 20+ 种),跑真 ACP 子进程。本开发沙箱不能联网 / 绑 socket、无真实 key, +**完整 ACP 套件在此跑不了**。故测试分五层,明确「哪些现在能真跑」: + +| 层 | 位置 | 覆盖 | 沙箱可跑 | +|---|---|---|---| +| L1 hooks 包单测 | `internal/hooks/*_test.go` | 命令 runner(真起子进程/临时脚本)、exit code 0/2/其它、stdout JSON 解析、matcher(精确/`A\|B`/正则/别名)、三层 hooks.json 合并、超时=放行、决策折叠(任一 deny→deny) | ✅ 是 | +| L2 中间件单测 | `internal/agent/hook_middleware_test.go` | `WrapInvokableToolCall`:PreToolUse deny 阻断(endpoint 不被调)、`updatedInput` 改参、allow→ctx 置 pre-approved、PostToolUse `modifiedResult` 改结果、PostToolUseFailure 分支 | ✅ 是(mock dispatcher + spy endpoint) | +| L3 续跑逻辑单测 | `internal/runner/*_test.go` | 把统一续跑决策抽成纯函数 `nextContinuation(...)`,测:todo/goal/Stop 优先级、umbrella budget 收敛、`stop_hook_active` 跨轮、`ctx.Done()` 一票否决 | ✅ 是(纯函数,无需 fake agent) | +| L4 真 agent 集成 | `internal/agent/hook_e2e_test.go` | scripted `ToolCallingChatModel` 发一个 tool_call → 真 dispatcher + 真 hookMiddleware + 真 `NewAgent`:断言 hook 脚本落下证据文件 / deny 真阻断工具 | ⚠️ 尝试;AsyncIterator 接线繁琐则降级为文档 | +| L5 ACP e2e | `agent-eval/suite/testcases.json` | 真 LLM 驱动:PreToolUse deny→`file_absent`、PostToolUse 脚本→证据文件、Stop 门禁续跑。用隔离 HOME 注入 `.jcode/hooks.json` | ❌ 需真实 key,交付用户跑 | + +**决定论要点**:L1–L4 全程无 LLM、无网络,用「hook 脚本写证据文件 / spy endpoint / 纯函数」三种确定性断言。 +L5 沿用 jcode 既有 oracle(`file_absent`/`file_contains`/`bounded_tool_calls`/`final_text_contains`), +LLM 不确定但 oracle 只看**最终副作用**,故判定确定。 + +**L5 用例草案**(交付 `testcases.json`): +```json +{ "id": "hook_pretooluse_deny", "prompt": "创建 forbidden.txt 内容 x", + "home_fixtures": { ".jcode/hooks.json": + "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"write_file\",\"hooks\":[{\"type\":\"command\",\"command\":\"exit 2\"}]}]}}" }, + "oracles": [ {"type":"file_absent","path":"forbidden.txt"}, {"type":"bounded_tool_calls","max":6} ] } +``` + +**验收标准(本会话)**:`go test ./internal/hooks/... ./internal/agent/... ./internal/runner/...` 全绿; +L5 用例写入 testcases.json 但标注需真实 key。 + +--- + +## 11. v1 实现状态 & 对抗审查结论 + +**已实现(三端通用)**:`internal/hooks/`(dispatcher/config 三层加载/matcher/命令 runner/parser)+ +`internal/agent/hook_middleware.go`(pre 在 approval 外、post 在内)+ approval 预授权短路 + runner 统一续跑管线 +(含 Stop hook)+ TUI/Web/ACP 三端经 `hooks.NewSessionDispatcher` 注入 ctx。事件:SessionStart / +UserPromptSubmit / PreToolUse / PostToolUse / PostToolUseFailure / Stop。测试 28 项全绿(dispatcher 18 + +中间件 8 + 续跑 2),另有 `agent-eval` 两个真实 LLM 用例(`hook_pretooluse_deny`/`hook_posttooluse_side_effect`)。 + +**对抗审查已修**: +- **C1/C2(RCE + allow 提权)** → 项目级 hooks **默认不加载**,仅 `~/.jcode/hooks.json`;项目层需 + `JCODE_HOOKS_TRUST_PROJECT=1` 显式开启(§7)。 +- **H1(Web/ACP 未接线)** → 三端已统一注入 dispatcher。 +- **H2(payload 被清空)** → `mustJSON` 失败时只丢 `tool_input`,保留 tool_name/事件名等。 +- **H3(matcher 子串误匹配)** → 精确优先(§4.3)。 +- **中危-4(续跑预算回归)** → umbrella = 25 + todo 3 = 28,保住 goal 原 25。 +- **高危-3(async goroutine 泄漏)** → async hook 加 30s 硬上限。 + +**审查确认健壮**:续跑无界/死循环(25 硬顶 + todo 子顶 + `ctx.Done()` 一票否决 + `stop_hook_active` 防自锁)、 +post hook 拿到真实 err、超时 fail-safe、regex 缓存并发安全、deny 短路不误伤 PostToolUse、pre-approved 不跨会话泄漏。 + +**v1 已知边界(后续)**:**工具类 hook(PreToolUse/PostToolUse/PostToolUseFailure/Stop)三端(TUI/Web/ACP) +均生效**;但 **prompt 级 hook(UserPromptSubmit / SessionStart)目前仅 TUI**——Web/ACP 上要正确接线需处理各自 +的「拒绝 prompt」响应语义和 SessionStart 的 per-session 生命周期,列为 follow-up。因项目级 hook 默认不加载 +(只用户自己的 `~/.jcode/hooks.json`),此差异是功能不完整而非提权。其余:`Decision.SystemMessage` 仅落日志未 +surface 到 UI(M1);PreToolUse 的 `additionalContext` 拼在结果后而非工具前(M3);SessionStart payload 缺 +`model`/`source`(L6);Pre/PostCompact 事件(常量暂未定义,见 `types.go`)、trust-on-first-use(hash 信任 UI)、 +`/hooks` 管理面板、teammate/subagent 的 hook 覆盖。 + +--- + +## 附:为什么挂中间件而不是 handler(一句话) + +`AgentEventHandler`(`internal/handler`)是 **per-transport 的展示层**(TUI/Web/ACP 各一份实现); +而工具审批/执行早已收敛在 **transport 无关的 `approvalMiddleware`**。把 hook 挂在中间件 + runner 层, +**一次实现,三端生效**;挂在 handler 层则要在每个 transport 重复接线,且拿不到「改写参数/阻断执行」的能力。 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 243054a..0e4fdd8 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -32,11 +32,17 @@ func NewAgent( handlers []adk.ChatModelAgentMiddleware, ) (*adk.ChatModelAgent, error) { // Handler order is outermost → innermost: tracing middlewares first, then the - // caller's handlers, then approval + safe-tool-error innermost so that - // summarization/reduction see the raw tool output first. + // caller's handlers, then the hook + approval + safe-tool-error stack innermost + // so that summarization/reduction see the raw tool output first. enhanced := append([]adk.ChatModelAgentMiddleware{}, middlewares...) enhanced = append(enhanced, handlers...) + // PreToolUse hook sits OUTSIDE approval: it can deny, rewrite args, or mark the + // call pre-approved (so approval skips its prompt) before the gate runs. + enhanced = append(enhanced, newPreHookMiddleware()) enhanced = append(enhanced, newApprovalMiddleware(approvalFunc)) + // PostToolUse hook sits INSIDE approval, wrapping the raw tool, so it sees the + // true execution error and can rewrite the result. + enhanced = append(enhanced, newPostHookMiddleware()) // Innermost: memory usage accounting observes approved executions only // and sees raw endpoint errors (a failed read is not memory usage). enhanced = append(enhanced, memory.NewUsageMiddleware()) diff --git a/internal/agent/hook_middleware.go b/internal/agent/hook_middleware.go new file mode 100644 index 0000000..0f3a5b0 --- /dev/null +++ b/internal/agent/hook_middleware.go @@ -0,0 +1,125 @@ +package agent + +import ( + "context" + "encoding/json" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/tool" + + "github.com/cnjack/jcode/internal/hooks" +) + +// hookToolMiddleware fires the tool-related hooks around a tool invocation. It is +// inserted twice into the chain (see NewAgent): +// +// - pre (post=false): sits OUTSIDE the approval middleware. It runs PreToolUse, +// which can deny the call, rewrite its arguments, inject context, or mark the +// call pre-approved so the approval gate skips its user prompt. +// - post (post=true): sits INSIDE the approval middleware, wrapping the raw +// tool. It sees the real execution error (the approval layer folds errors into +// strings), so it can distinguish PostToolUse from PostToolUseFailure and +// rewrite the result. +// +// The dispatcher is read from the context (injected by the command surface), so a +// session with no hooks configured makes these middlewares free no-ops. +type hookToolMiddleware struct { + *adk.BaseChatModelAgentMiddleware + post bool +} + +func newPreHookMiddleware() adk.ChatModelAgentMiddleware { + return &hookToolMiddleware{BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}, post: false} +} + +func newPostHookMiddleware() adk.ChatModelAgentMiddleware { + return &hookToolMiddleware{BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}, post: true} +} + +func (m *hookToolMiddleware) WrapInvokableToolCall( + ctx context.Context, + endpoint adk.InvokableToolCallEndpoint, + tCtx *adk.ToolContext, +) (adk.InvokableToolCallEndpoint, error) { + if m.post { + return m.wrapPost(endpoint, tCtx), nil + } + return m.wrapPre(endpoint, tCtx), nil +} + +// wrapPre handles PreToolUse. +func (m *hookToolMiddleware) wrapPre(endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) adk.InvokableToolCallEndpoint { + return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { + disp := hooks.DispatcherFromContext(ctx) + if !disp.Configured(hooks.PreToolUse) { + return endpoint(ctx, args, opts...) + } + dec := disp.Fire(ctx, hooks.PreToolUse, hooks.Payload{ + ToolName: tCtx.Name, + ToolInput: json.RawMessage(args), + }) + if dec.Denied() { + return hookDenyMessage(dec.Reason), nil + } + if len(dec.UpdatedInput) > 0 { + args = string(dec.UpdatedInput) + } + if dec.Permission == hooks.PermAllow { + // Let the inner approval gate skip its user prompt for this call. + ctx = hooks.WithPreApproved(ctx) + } + result, err := endpoint(ctx, args, opts...) + if dec.AdditionalContext != "" { + result = appendHookContext(result, dec.AdditionalContext) + } + return result, err + } +} + +// wrapPost handles PostToolUse / PostToolUseFailure. +func (m *hookToolMiddleware) wrapPost(endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) adk.InvokableToolCallEndpoint { + return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { + result, err := endpoint(ctx, args, opts...) + + event := hooks.PostToolUse + if err != nil { + event = hooks.PostToolUseFailure + } + disp := hooks.DispatcherFromContext(ctx) + if !disp.Configured(event) { + return result, err + } + dec := disp.Fire(ctx, event, hooks.Payload{ + ToolName: tCtx.Name, + ToolInput: json.RawMessage(args), + ToolResponse: result, + }) + if dec.ModifiedResult != nil { + result = *dec.ModifiedResult + } + if dec.AdditionalContext != "" { + result = appendHookContext(result, dec.AdditionalContext) + } + return result, err + } +} + +// hookDenyMessage is returned to the model when a PreToolUse hook blocks a tool. +// It mirrors the approval-rejection wording so the model does not try to work +// around the policy. +func hookDenyMessage(reason string) string { + msg := "Tool execution was blocked by a hook policy." + if reason != "" { + msg += " Reason: " + reason + } + msg += " IMPORTANT: Do NOT retry this or attempt a workaround with a different tool or command. " + + "Respect the policy and either choose a different approach or ask the user how to proceed." + return msg +} + +func appendHookContext(result, ctxText string) string { + if result == "" { + return ctxText + } + return result + "\n\n" + ctxText +} diff --git a/internal/agent/hook_middleware_test.go b/internal/agent/hook_middleware_test.go new file mode 100644 index 0000000..2d02946 --- /dev/null +++ b/internal/agent/hook_middleware_test.go @@ -0,0 +1,207 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + + "os" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/tool" + + "github.com/cnjack/jcode/internal/hooks" +) + +// fakeDispatcher is a programmable hooks.Dispatcher for middleware unit tests. +type fakeDispatcher struct { + configured map[hooks.Event]bool + fire func(hooks.Event, hooks.Payload) hooks.Decision + fired []hooks.Event +} + +func (f *fakeDispatcher) Configured(e hooks.Event) bool { return f.configured[e] } +func (f *fakeDispatcher) Fire(_ context.Context, e hooks.Event, p hooks.Payload) hooks.Decision { + f.fired = append(f.fired, e) + if f.fire != nil { + return f.fire(e, p) + } + return hooks.Decision{} +} + +func ctxWith(f hooks.Dispatcher) context.Context { + return hooks.WithDispatcher(context.Background(), f) +} + +func TestPreHookDenyBlocksTool(t *testing.T) { + fake := &fakeDispatcher{ + configured: map[hooks.Event]bool{hooks.PreToolUse: true}, + fire: func(hooks.Event, hooks.Payload) hooks.Decision { + return hooks.Decision{Permission: hooks.PermDeny, Reason: "nope"} + }, + } + called := false + endpoint := func(context.Context, string, ...tool.Option) (string, error) { + called = true + return "ran", nil + } + wrapped, _ := newPreHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "write"}) + out, err := wrapped(ctxWith(fake), `{"path":"x"}`) + if err != nil { + t.Fatal(err) + } + if called { + t.Error("endpoint must not run after a PreToolUse deny") + } + if !strings.Contains(out, "nope") { + t.Errorf("deny message missing reason: %q", out) + } +} + +func TestPreHookUpdatedInputRewritesArgs(t *testing.T) { + fake := &fakeDispatcher{ + configured: map[hooks.Event]bool{hooks.PreToolUse: true}, + fire: func(hooks.Event, hooks.Payload) hooks.Decision { + return hooks.Decision{UpdatedInput: json.RawMessage(`{"path":"safe"}`)} + }, + } + var gotArgs string + endpoint := func(_ context.Context, args string, _ ...tool.Option) (string, error) { + gotArgs = args + return "ok", nil + } + wrapped, _ := newPreHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "write"}) + if _, err := wrapped(ctxWith(fake), `{"path":"danger"}`); err != nil { + t.Fatal(err) + } + if gotArgs != `{"path":"safe"}` { + t.Errorf("args=%q want rewritten to safe", gotArgs) + } +} + +func TestPreHookAllowMarksPreApproved(t *testing.T) { + fake := &fakeDispatcher{ + configured: map[hooks.Event]bool{hooks.PreToolUse: true}, + fire: func(hooks.Event, hooks.Payload) hooks.Decision { + return hooks.Decision{Permission: hooks.PermAllow} + }, + } + var preApproved bool + endpoint := func(ctx context.Context, _ string, _ ...tool.Option) (string, error) { + preApproved = hooks.IsPreApproved(ctx) + return "ok", nil + } + wrapped, _ := newPreHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "write"}) + if _, err := wrapped(ctxWith(fake), `{}`); err != nil { + t.Fatal(err) + } + if !preApproved { + t.Error("PreToolUse allow should mark ctx pre-approved for the approval gate") + } +} + +func TestPreHookAdditionalContextAppended(t *testing.T) { + fake := &fakeDispatcher{ + configured: map[hooks.Event]bool{hooks.PreToolUse: true}, + fire: func(hooks.Event, hooks.Payload) hooks.Decision { + return hooks.Decision{AdditionalContext: "remember X"} + }, + } + endpoint := func(context.Context, string, ...tool.Option) (string, error) { return "result", nil } + wrapped, _ := newPreHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "read"}) + out, _ := wrapped(ctxWith(fake), `{}`) + if !strings.Contains(out, "result") || !strings.Contains(out, "remember X") { + t.Errorf("expected result + context, got %q", out) + } +} + +func TestPreHookNotConfiguredIsPassthrough(t *testing.T) { + fake := &fakeDispatcher{configured: map[hooks.Event]bool{}} // nothing configured + called := false + endpoint := func(context.Context, string, ...tool.Option) (string, error) { + called = true + return "ok", nil + } + wrapped, _ := newPreHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "write"}) + if _, err := wrapped(ctxWith(fake), `{}`); err != nil { + t.Fatal(err) + } + if !called { + t.Error("with no PreToolUse hook, the endpoint must run unmodified") + } + if len(fake.fired) != 0 { + t.Error("Fire must not be called when the event is not configured") + } +} + +func TestPostHookModifiesResult(t *testing.T) { + modified := "REDACTED" + fake := &fakeDispatcher{ + configured: map[hooks.Event]bool{hooks.PostToolUse: true}, + fire: func(hooks.Event, hooks.Payload) hooks.Decision { + return hooks.Decision{ModifiedResult: &modified} + }, + } + endpoint := func(context.Context, string, ...tool.Option) (string, error) { return "secret", nil } + wrapped, _ := newPostHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "read"}) + out, _ := wrapped(ctxWith(fake), `{}`) + if out != "REDACTED" { + t.Errorf("PostToolUse should replace result, got %q", out) + } +} + +func TestPostHookFailureEventOnError(t *testing.T) { + fake := &fakeDispatcher{ + configured: map[hooks.Event]bool{hooks.PostToolUseFailure: true}, + } + endpoint := func(context.Context, string, ...tool.Option) (string, error) { + return "", errors.New("boom") + } + wrapped, _ := newPostHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "execute"}) + _, err := wrapped(ctxWith(fake), `{}`) + if err == nil || err.Error() != "boom" { + t.Errorf("error should propagate, got %v", err) + } + if len(fake.fired) != 1 || fake.fired[0] != hooks.PostToolUseFailure { + t.Errorf("expected PostToolUseFailure fired, got %v", fake.fired) + } +} + +// TestRealDispatcherThroughRealMiddleware wires the REAL dispatcher (loaded from a +// real hooks.json running a real subprocess) through the REAL PreToolUse +// middleware — closing the seam between the L1 (dispatcher) and L2 (middleware) +// unit tests. +func TestRealDispatcherThroughRealMiddleware(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "hooks.json"), []byte( + `{"hooks":{"PreToolUse":[{"matcher":"write","hooks":[{"type":"command","command":"echo policy-block >&2; exit 2"}]}]}}`, + ), 0o644); err != nil { + t.Fatal(err) + } + + cfg, warns := hooks.Load(home, t.TempDir(), false) + if len(warns) != 0 { + t.Fatalf("warnings: %v", warns) + } + disp := hooks.NewDispatcher(cfg, hooks.Options{CWD: t.TempDir()}) + + called := false + endpoint := func(context.Context, string, ...tool.Option) (string, error) { + called = true + return "wrote file", nil + } + wrapped, _ := newPreHookMiddleware().WrapInvokableToolCall(context.Background(), endpoint, &adk.ToolContext{Name: "write"}) + out, err := wrapped(hooks.WithDispatcher(context.Background(), disp), `{"path":"forbidden.txt"}`) + if err != nil { + t.Fatal(err) + } + if called { + t.Error("real hook exit 2 must block the real tool endpoint") + } + if !strings.Contains(out, "policy-block") { + t.Errorf("expected stderr reason surfaced, got %q", out) + } +} diff --git a/internal/command/acp.go b/internal/command/acp.go index 72d848e..361503e 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -21,6 +21,7 @@ import ( "github.com/cnjack/jcode/internal/agent" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/hooks" mempipeline "github.com/cnjack/jcode/internal/memory/pipeline" "github.com/cnjack/jcode/internal/mode" internalmodel "github.com/cnjack/jcode/internal/model" @@ -669,6 +670,14 @@ func (a *acpAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Pr copy(history, sess.history) sess.mu.Unlock() + // Inject the hook dispatcher so PreToolUse/PostToolUse/Stop hooks run on ACP + // too (parity with the TUI); reloaded per turn so hooks.json edits hot-apply. + // The recorder is optional here, so fall back to the ACP session id. + hookSessionID := string(params.SessionId) + if sess.rec != nil { + hookSessionID = sess.rec.UUID() + } + promptCtx = hooks.WithDispatcher(promptCtx, hooks.NewSessionDispatcher(config.ConfigDir(), sess.env.Pwd(), hookSessionID, config.Logger().Printf)) resp := runner.Run(promptCtx, sess.ag, history, sess.h, sess.rec, sess.todoStore, sess.env.GoalStore, sess.tracer, sess.tokenUsage) sess.mu.Lock() diff --git a/internal/command/interactive.go b/internal/command/interactive.go index b3b0481..7a28af5 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -24,6 +24,7 @@ import ( "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/hooks" mempipeline "github.com/cnjack/jcode/internal/memory/pipeline" "github.com/cnjack/jcode/internal/mode" internalmodel "github.com/cnjack/jcode/internal/model" @@ -71,6 +72,13 @@ type interactiveState struct { sessionResumeWarning string sessionBaselineCommit string + // hookDisp fires user-configured hooks at agent-loop lifecycle points. Always + // non-nil (a no-op dispatcher when no hooks are configured). + hookDisp hooks.Dispatcher + // hookStartContext holds SessionStart additionalContext, prepended to the + // first user prompt then cleared. + hookStartContext string + // WeChat channel wechatClient *weixin.Client agentRunning atomic.Bool @@ -384,9 +392,36 @@ func (s *interactiveState) handlePrompt(userPrompt string) { userPrompt = s.sessionResumeWarning + "\n\n" + userPrompt s.sessionResumeWarning = "" } + // UserPromptSubmit hook: may block this prompt or inject extra context. Fires + // BEFORE anything is recorded so a denied prompt leaves the transcript and the + // in-memory history consistent (neither contains it). + if s.hookDisp != nil && s.hookDisp.Configured(hooks.UserPromptSubmit) { + dec := s.hookDisp.Fire(runCtx, hooks.UserPromptSubmit, hooks.Payload{Prompt: userPrompt}) + if dec.Denied() { + msg := "Your message was blocked by a hook policy." + if dec.Reason != "" { + msg += " Reason: " + dec.Reason + } + s.h.OnAgentText(msg + "\n") + s.h.OnAgentDone(nil) + return + } + if dec.AdditionalContext != "" { + userPrompt = userPrompt + "\n\n" + dec.AdditionalContext + } + } + // Prepend one-shot SessionStart context to the first prompt of the session. + if s.hookStartContext != "" { + userPrompt = s.hookStartContext + "\n\n" + userPrompt + s.hookStartContext = "" + } + + // Record after the hooks so transcript and history reflect the same final + // prompt (and nothing is recorded when the prompt is denied above). if s.rec != nil { s.rec.RecordUser(userPrompt) } + if s.agentTokenUsage == nil { s.agentTokenUsage = &internalmodel.TokenUsage{} } @@ -969,6 +1004,13 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { rec, _ := session.NewRecorder(pwd, providerName, modelName) + // Build the transport-agnostic hook dispatcher and inject it into the context + // so the tool hook middleware and the runner's continuation loop reach it + // without signature changes. Project-level hooks are untrusted and load only + // under JCODE_HOOKS_TRUST_PROJECT=1 (see hooks.NewSessionDispatcher). + hookDisp := hooks.NewSessionDispatcher(config.ConfigDir(), pwd, rec.UUID(), config.Logger().Printf) + ctx = hooks.WithDispatcher(ctx, hookDisp) + env.TodoStore.OnUpdate = func(items []tools.TodoItem) { if rec != nil { snapItems := make([]session.TodoSnapshotItem, len(items)) @@ -1007,6 +1049,16 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { askUserDeps: askUserDeps, mcpTools: mcpTools, rec: rec, + hookDisp: hookDisp, + } + + // SessionStart hook: fire once for a fresh session; stash any additionalContext + // to prepend to the first prompt. A resumed session fires it later — after the + // recorder UUID is restored — to avoid a double-fire and a wrong session_id. + // Non-blocking. + if resumeUUID == "" && hookDisp.Configured(hooks.SessionStart) { + dec := hookDisp.Fire(ctx, hooks.SessionStart, hooks.Payload{}) + st.hookStartContext = dec.AdditionalContext } // Initialize WeChat channel @@ -1280,6 +1332,15 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { // Reuse the existing session UUID so new messages are appended to the same file if st.rec != nil { st.rec.SetUUID(resumeUUID) + // The dispatcher + SessionStart during setup bound to the throwaway UUID; + // rebuild against the restored one so hook payloads carry the correct + // session_id, then fire SessionStart now — once — for the real session. + st.hookDisp = hooks.NewSessionDispatcher(config.ConfigDir(), pwd, st.rec.UUID(), config.Logger().Printf) + st.ctx = hooks.WithDispatcher(st.ctx, st.hookDisp) + if st.hookDisp.Configured(hooks.SessionStart) { + dec := st.hookDisp.Fire(st.ctx, hooks.SessionStart, hooks.Payload{}) + st.hookStartContext = dec.AdditionalContext + } } } diff --git a/internal/hooks/config.go b/internal/hooks/config.go new file mode 100644 index 0000000..1b64433 --- /dev/null +++ b/internal/hooks/config.go @@ -0,0 +1,101 @@ +package hooks + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Layer file names, lowest → highest precedence. All matching groups from every +// layer run (append semantics), so a project hook never silently disables a user +// hook; it only adds to it. +const ( + userHooksFile = "hooks.json" // ~/.jcode/hooks.json + projectHooksFile = "hooks.json" // /.jcode/hooks.json + localHooksFile = "hooks.local.json" // /.jcode/hooks.local.json (gitignored) +) + +// Load reads and merges the hooks.json layers. +// +// configDir — the ~/.jcode directory (config.ConfigDir()). +// workDir — the project working directory; its .jcode/ holds project layers. +// trustProject — whether to load the project layers (.jcode/hooks.json and +// .jcode/hooks.local.json). +// +// SECURITY: only the user layer (~/.jcode/hooks.json) is trusted by default. A +// hook runs arbitrary commands the moment its event fires (SessionStart fires on +// startup) and can auto-approve tools, so honoring project-provided hooks would +// be arbitrary code execution from an untrusted clone. Project layers therefore +// load only when trustProject is true — a stand-in until trust-on-first-use +// (per-hook hash confirmation, see internal-doc/hooks-design.md §7) lands. +// +// Missing files are skipped silently. Malformed files are skipped with a warning +// (returned, not fatal) so one broken layer cannot brick the agent. +func Load(configDir, workDir string, trustProject bool) (Config, []string) { + paths := []string{filepath.Join(configDir, userHooksFile)} + if trustProject { + paths = append(paths, + filepath.Join(workDir, ".jcode", projectHooksFile), + filepath.Join(workDir, ".jcode", localHooksFile), + ) + } + merged := Config{Hooks: map[string][]HookGroup{}} + var warnings []string + for _, p := range paths { + c, err := loadFile(p) + if err != nil { + if !os.IsNotExist(err) { + warnings = append(warnings, fmt.Sprintf("hooks: skipping %s: %v", p, err)) + } + continue + } + for ev, groups := range c.Hooks { + merged.Hooks[ev] = append(merged.Hooks[ev], groups...) + } + } + return merged, warnings +} + +// trustProjectEnv is the opt-in that loads untrusted project-layer hooks. +const trustProjectEnv = "JCODE_HOOKS_TRUST_PROJECT" + +// NewSessionDispatcher loads the hook config for a session and builds a +// Dispatcher, honoring the project-trust env gate. Shared by all command +// surfaces (TUI/Web/ACP) so hooks behave identically everywhere. +// +// configDir — the ~/.jcode directory (config.ConfigDir()). +// cwd — the session working directory. +// sessionID — the session UUID (for the hook payload). +func NewSessionDispatcher(configDir, cwd, sessionID string, logf func(string, ...any)) Dispatcher { + trustProject := os.Getenv(trustProjectEnv) == "1" + cfg, warns := Load(configDir, cwd, trustProject) + for _, w := range warns { + if logf != nil { + logf("%s", w) + } + } + return NewDispatcher(cfg, Options{CWD: cwd, SessionID: sessionID, Logf: logf}) +} + +func loadFile(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + var c Config + if err := json.Unmarshal(data, &c); err != nil { + return Config{}, fmt.Errorf("hooks: invalid JSON: %w", err) + } + return c, nil +} + +// Empty reports whether the config defines no hooks at all. +func (c Config) Empty() bool { + for _, groups := range c.Hooks { + if len(groups) > 0 { + return false + } + } + return true +} diff --git a/internal/hooks/context.go b/internal/hooks/context.go new file mode 100644 index 0000000..92ac4a1 --- /dev/null +++ b/internal/hooks/context.go @@ -0,0 +1,44 @@ +package hooks + +import "context" + +type ctxKey int + +const ( + preApprovedKey ctxKey = iota + dispatcherKey +) + +// WithDispatcher stores the session's Dispatcher on the context so the tool hook +// middleware and the runner's continuation loop can reach it without threading it +// through every signature. Command surfaces (TUI/Web/ACP) inject it into the ctx +// they hand to the agent run. +func WithDispatcher(ctx context.Context, d Dispatcher) context.Context { + return context.WithValue(ctx, dispatcherKey, d) +} + +// DispatcherFromContext returns the Dispatcher on the context, or a no-op +// dispatcher when none was injected, so callers never need a nil check. +func DispatcherFromContext(ctx context.Context) Dispatcher { + if d, ok := ctx.Value(dispatcherKey).(Dispatcher); ok && d != nil { + return d + } + return nopDispatcher{} +} + +// WithPreApproved marks the context so a downstream approval gate skips its user +// prompt. The PreToolUse hook middleware sets this when a hook returns +// permissionDecision=allow; the approval layer reads it via IsPreApproved. +// +// It is intentionally per-call (context.WithValue, not persisted across +// interrupt/resume): a pre-approval only applies to the single tool invocation +// whose ctx carries it. +func WithPreApproved(ctx context.Context) context.Context { + return context.WithValue(ctx, preApprovedKey, true) +} + +// IsPreApproved reports whether a PreToolUse hook already authorized this call. +func IsPreApproved(ctx context.Context) bool { + v, _ := ctx.Value(preApprovedKey).(bool) + return v +} diff --git a/internal/hooks/dispatcher.go b/internal/hooks/dispatcher.go new file mode 100644 index 0000000..d4c0404 --- /dev/null +++ b/internal/hooks/dispatcher.go @@ -0,0 +1,212 @@ +package hooks + +import ( + "context" + "encoding/json" + "os" +) + +// Dispatcher fires the hooks configured for an event and folds their outcomes +// into a single Decision. Implementations must be safe for concurrent use. +type Dispatcher interface { + // Fire runs every matching hook for event with the given payload and returns + // the folded decision. The zero Decision means "no opinion — proceed". + Fire(ctx context.Context, event Event, p Payload) Decision + // Configured reports whether any hook is registered for event, letting hot + // paths skip payload construction when hooks are unused. + Configured(event Event) bool +} + +// Options parameterize a Dispatcher with per-session context and hooks. +type Options struct { + CWD string + SessionID string + TranscriptPath string + Env []string // base env; nil → os.Environ() + Logf func(string, ...any) +} + +// NewDispatcher builds a Dispatcher from a merged Config. When the config defines +// no hooks it returns a no-op dispatcher whose Fire is free, so callers can wire +// it unconditionally. +func NewDispatcher(cfg Config, opts Options) Dispatcher { + if cfg.Empty() { + return nopDispatcher{} + } + env := opts.Env + if env == nil { + env = os.Environ() + } + return &dispatcher{ + cfg: cfg, + cwd: opts.CWD, + sessionID: opts.SessionID, + transcriptPath: opts.TranscriptPath, + baseEnv: env, + logf: opts.Logf, + } +} + +type dispatcher struct { + cfg Config + cwd string + sessionID string + transcriptPath string + baseEnv []string + logf func(string, ...any) +} + +func (d *dispatcher) Configured(event Event) bool { + return len(d.cfg.Hooks[string(event)]) > 0 +} + +func (d *dispatcher) Fire(ctx context.Context, event Event, p Payload) Decision { + specs := d.selectSpecs(event, p.ToolName) + if len(specs) == 0 { + return Decision{} + } + + // Fill session-scoped payload defaults. + p.HookEventName = string(event) + if p.CWD == "" { + p.CWD = d.cwd + } + if p.SessionID == "" { + p.SessionID = d.sessionID + } + if p.TranscriptPath == "" { + p.TranscriptPath = d.transcriptPath + } + + var dec Decision + for _, s := range specs { + if s.Async && !event.Blockable() { + // Fire-and-forget: cannot influence the decision. Detach from ctx so a + // turn ending mid-notification does not truncate it, but bound it with a + // hard cap so a hung async hook cannot leak a goroutine indefinitely. + payload := p + spec := s + env := hookEnv(d.baseEnv, payload) + input := mustJSON(payload) + go func() { + actx, acancel := context.WithTimeout(context.WithoutCancel(ctx), asyncHardCap) + defer acancel() + runHook(actx, spec, input, payload.CWD, env, event, d.logf) + }() + continue + } + + out := runHook(ctx, s, mustJSON(p), p.CWD, hookEnv(d.baseEnv, p), event, d.logf) + fold(&dec, out, event, &p) + + // Once denied/blocked, later hooks cannot un-deny and any input rewrite is + // moot — stop early. + if dec.Permission == PermDeny || dec.Block { + break + } + } + return dec +} + +// selectSpecs returns the flattened hook specs whose matcher applies. +func (d *dispatcher) selectSpecs(event Event, toolName string) []HookSpec { + groups := d.cfg.Hooks[string(event)] + if len(groups) == 0 { + return nil + } + var specs []HookSpec + for _, g := range groups { + if !matchesTool(g.Matcher, toolName) { + continue + } + for _, h := range g.Hooks { + // v1 only supports command hooks; empty type defaults to command. + if h.Type != "" && h.Type != "command" { + if d.logf != nil { + d.logf("hooks: unsupported hook type %q ignored", h.Type) + } + continue + } + specs = append(specs, h) + } + } + return specs +} + +// fold merges one hook's outcome into the running Decision and chains any input / +// result rewrite into the payload so the next hook sees the updated value. +func fold(dec *Decision, out runOutcome, event Event, p *Payload) { + dec.Permission = upgradePerm(dec.Permission, out.permission) + if out.block { + dec.Block = true + } + if event == PreToolUse && dec.Permission == PermDeny { + dec.Block = true + } + if (out.block || out.permission == PermDeny) && out.reason != "" && dec.Reason == "" { + dec.Reason = out.reason + } + + if out.updatedInput != nil { + dec.UpdatedInput = out.updatedInput + p.ToolInput = out.updatedInput // chain into the next hook + } + if out.modifiedResult != nil { + dec.ModifiedResult = out.modifiedResult + p.ToolResponse = *out.modifiedResult // chain into the next hook + } + if out.additionalContext != "" { + dec.AdditionalContext = joinLines(dec.AdditionalContext, out.additionalContext) + } + if out.systemMessage != "" { + dec.SystemMessage = joinLines(dec.SystemMessage, out.systemMessage) + } +} + +// upgradePerm folds two permission verdicts with precedence deny > ask > allow. +func upgradePerm(cur, next Permission) Permission { + if rank(next) > rank(cur) { + return next + } + return cur +} + +func rank(p Permission) int { + switch p { + case PermAllow: + return 1 + case PermAsk: + return 2 + case PermDeny: + return 3 + default: + return 0 + } +} + +func joinLines(a, b string) string { + if a == "" { + return b + } + return a + "\n" + b +} + +func mustJSON(p Payload) []byte { + data, err := json.Marshal(p) + if err != nil { + // The only field that can make marshalling fail is ToolInput (a + // json.RawMessage holding non-JSON tool args). Drop it and retry so the + // rest of the payload — tool_name, event, cwd — still reaches the hook. + p.ToolInput = nil + if data, err = json.Marshal(p); err != nil { + return []byte("{}") + } + } + return data +} + +// nopDispatcher is returned when no hooks are configured. +type nopDispatcher struct{} + +func (nopDispatcher) Fire(context.Context, Event, Payload) Decision { return Decision{} } +func (nopDispatcher) Configured(Event) bool { return false } diff --git a/internal/hooks/exec.go b/internal/hooks/exec.go new file mode 100644 index 0000000..5ec1fd6 --- /dev/null +++ b/internal/hooks/exec.go @@ -0,0 +1,217 @@ +package hooks + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "strings" + "time" +) + +// defaultTimeout is applied to a hook that does not set its own. Generous +// compared to Claude Code's 30s because a Stop-gate hook may run a test suite. +const defaultTimeout = 60 * time.Second + +// asyncHardCap bounds fire-and-forget (async) hooks regardless of their +// configured timeout, so a slow or hung async hook cannot leak a goroutine for +// the life of the (detached) process. +const asyncHardCap = 30 * time.Second + +// maxHookOutput caps how much of a hook's stdout/stderr is captured, so a chatty +// or runaway hook cannot exhaust memory. +const maxHookOutput = 1 << 20 // 1 MiB per stream + +// capWriter buffers up to max bytes and silently drops the rest, while always +// reporting a full write so the hook process is never blocked on a full pipe. +type capWriter struct { + buf bytes.Buffer + max int +} + +func (w *capWriter) Write(p []byte) (int, error) { + n := len(p) + if remaining := w.max - w.buf.Len(); remaining > 0 { + if n > remaining { + w.buf.Write(p[:remaining]) + } else { + w.buf.Write(p) + } + } + return n, nil +} + +func (w *capWriter) Bytes() []byte { return w.buf.Bytes() } +func (w *capWriter) String() string { return w.buf.String() } + +// runOutcome is one hook command's contribution to the folded Decision. +type runOutcome struct { + permission Permission + block bool + reason string + updatedInput json.RawMessage + modifiedResult *string + additionalContext string + systemMessage string +} + +// runHook executes a single hook command, feeding it the JSON payload over stdin +// and interpreting its exit code + stdout per the Claude Code protocol. +// +// Fail-safe rules (never let a broken hook wedge the agent): +// - timeout → treated as allow/no-op (logged). +// - parent ctx cancel → aborted, no-op (the whole operation is unwinding). +// - failed to spawn → no-op error surfaced as a system message. +// - unexpected code → non-blocking; stderr shown, no decision effect. +func runHook(ctx context.Context, spec HookSpec, input []byte, cwd string, env []string, event Event, logf func(string, ...any)) runOutcome { + timeout := defaultTimeout + if spec.Timeout > 0 { + timeout = time.Duration(spec.Timeout) * time.Second + } + tctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(tctx, "sh", "-c", spec.Command) + cmd.Dir = cwd + cmd.Stdin = bytes.NewReader(input) + cmd.Env = env + // Cap captured output so a chatty hook can't OOM the agent. + stdout := &capWriter{max: maxHookOutput} + stderr := &capWriter{max: maxHookOutput} + cmd.Stdout = stdout + cmd.Stderr = stderr + // Run the hook in its own process group (unix) so a timeout/cancel tears down + // the whole tree, not just `sh` — otherwise a grandchild it spawned (e.g. a + // `sleep`) survives and keeps the stdout pipe open. WaitDelay is the + // cross-platform backstop: it bounds cmd.Wait and force-closes the pipes so + // runHook returns promptly even if teardown is incomplete. + setupProcessGroup(cmd) + cmd.WaitDelay = 500 * time.Millisecond + + err := cmd.Run() + + // Parent cancellation (user stop): abort silently — the operation is ending. + if ctx.Err() != nil { + return runOutcome{} + } + // Our own timeout: fail-safe to allow, do not block the agent. + if tctx.Err() == context.DeadlineExceeded { + if logf != nil { + logf("hooks: %s hook timed out after %s (treated as allow): %s", event, timeout, spec.Command) + } + return runOutcome{} + } + + exitCode := 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + exitCode = ee.ExitCode() + } else { + // Could not start the process at all. + if logf != nil { + logf("hooks: %s hook failed to start: %v", event, err) + } + return runOutcome{systemMessage: "hook failed to run: " + err.Error()} + } + } + + return parseOutcome(event, exitCode, stdout.Bytes(), strings.TrimSpace(stderr.String()), logf) +} + +// parseOutcome maps a hook's exit code + stdout/stderr into a runOutcome. +func parseOutcome(event Event, exitCode int, stdout []byte, stderr string, logf func(string, ...any)) runOutcome { + var out runOutcome + + switch exitCode { + case 0: + // Success: structured stdout (if any) provides fine-grained control. + case 2: + // Block, but only for events that can actually be blocked. + if event.Blockable() { + if event == PreToolUse { + out.permission = PermDeny + } else { + out.block = true + } + out.reason = stderr + } else if logf != nil { + logf("hooks: %s hook exited 2 but event is non-blockable (ignored)", event) + } + return out + default: + // Non-blocking error: surface stderr, do not affect the decision. + if stderr != "" { + out.systemMessage = stderr + } + if logf != nil { + logf("hooks: %s hook exited %d (non-blocking): %s", event, exitCode, stderr) + } + return out + } + + // exit 0 → parse structured stdout if present. + trimmed := bytes.TrimSpace(stdout) + if len(trimmed) == 0 || trimmed[0] != '{' { + return out + } + var env hookOutput + if err := json.Unmarshal(trimmed, &env); err != nil { + if logf != nil { + logf("hooks: %s hook stdout is not valid JSON (ignored): %v", event, err) + } + return out + } + + if env.SystemMessage != "" { + out.systemMessage = env.SystemMessage + } + + // Envelope-level block controls (Stop / UserPromptSubmit). + if event.Blockable() && event != PreToolUse { + if env.Continue != nil && !*env.Continue { + out.block = true + out.reason = env.Reason + } + if env.Decision == "block" { + out.block = true + if out.reason == "" { + out.reason = env.Reason + } + } + } + + if hso := env.HookSpecificOutput; hso != nil { + if event == PreToolUse && hso.PermissionDecision != "" { + out.permission = Permission(hso.PermissionDecision) + out.reason = hso.PermissionDecisionReason + } + if len(hso.UpdatedInput) > 0 { + out.updatedInput = hso.UpdatedInput + } + if hso.ModifiedResult != nil { + out.modifiedResult = hso.ModifiedResult + } + if hso.AdditionalContext != "" { + out.additionalContext = hso.AdditionalContext + } + } + return out +} + +// hookEnv builds the environment for a hook process: the parent env plus the +// JCODE_* convenience variables so scripts can avoid parsing stdin. +func hookEnv(base []string, p Payload) []string { + if base == nil { + base = os.Environ() + } + return append(base, + "JCODE_SESSION_ID="+p.SessionID, + "JCODE_TOOL_NAME="+p.ToolName, + "JCODE_CWD="+p.CWD, + "JCODE_TRANSCRIPT_PATH="+p.TranscriptPath, + "JCODE_HOOK_EVENT="+p.HookEventName, + ) +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go new file mode 100644 index 0000000..7beaf24 --- /dev/null +++ b/internal/hooks/hooks_test.go @@ -0,0 +1,304 @@ +package hooks + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func cfg(hooks map[string][]HookGroup) Config { return Config{Hooks: hooks} } + +func group(matcher string, cmds ...HookSpec) HookGroup { + return HookGroup{Matcher: matcher, Hooks: cmds} +} + +func cmd(command string) HookSpec { return HookSpec{Type: "command", Command: command} } + +func newTestDispatcher(t *testing.T, c Config) (Dispatcher, string) { + t.Helper() + dir := t.TempDir() + d := NewDispatcher(c, Options{CWD: dir, SessionID: "sess-1"}) + return d, dir +} + +func TestMatchesTool(t *testing.T) { + cases := []struct { + matcher, tool string + want bool + }{ + {"", "write", true}, + {"*", "anything", true}, + {"write", "write", true}, + {"write|edit", "edit", true}, + {"write|edit", "read", false}, + {"^execute$", "execute", true}, + {"^execute$", "execute_something", false}, + {"mcp__.*", "mcp__github__list", true}, + {"mcp__.*", "read", false}, + {"Bash", "execute", true}, // alias: execute ↔ Bash + {"Write", "write", true}, // alias: write ↔ Write + {"read", "write", false}, // matcher miss + {"[invalid(", "write", false}, // bad regex → no match, no panic + {"write", "todowrite", false}, // exact-by-default: no substring footgun + {"read", "todoread", false}, // exact-by-default + {"read", "browser_read", false}, // exact-by-default + {"^write$", "todowrite", false}, // explicit anchor also excludes substring + {"todo.*", "todowrite", true}, // explicit regex still opts in + } + for _, c := range cases { + if got := matchesTool(c.matcher, c.tool); got != c.want { + t.Errorf("matchesTool(%q,%q)=%v want %v", c.matcher, c.tool, got, c.want) + } + } +} + +func TestLoadMerge(t *testing.T) { + home := t.TempDir() + work := t.TempDir() + must := func(path, content string) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + must(filepath.Join(home, "hooks.json"), + `{"hooks":{"PreToolUse":[{"matcher":"write","hooks":[{"type":"command","command":"echo user"}]}]}}`) + must(filepath.Join(work, ".jcode", "hooks.json"), + `{"hooks":{"PreToolUse":[{"matcher":"edit","hooks":[{"type":"command","command":"echo project"}]}],"Stop":[{"hooks":[{"type":"command","command":"echo stop"}]}]}}`) + must(filepath.Join(work, ".jcode", "hooks.local.json"), + `{"hooks":{"PreToolUse":[{"matcher":"read","hooks":[{"type":"command","command":"echo local"}]}]}}`) + + // trustProject=true loads all three layers. + merged, warnings := Load(home, work, true) + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + if got := len(merged.Hooks["PreToolUse"]); got != 3 { + t.Errorf("PreToolUse groups: got %d want 3 (user+project+local append)", got) + } + if got := len(merged.Hooks["Stop"]); got != 1 { + t.Errorf("Stop groups: got %d want 1", got) + } + if merged.Empty() { + t.Error("merged config should not be Empty()") + } + + // trustProject=false loads only the user layer; project/local are ignored. + userOnly, _ := Load(home, work, false) + if got := len(userOnly.Hooks["PreToolUse"]); got != 1 { + t.Errorf("untrusted load: PreToolUse groups=%d want 1 (user only)", got) + } + if len(userOnly.Hooks["Stop"]) != 0 { + t.Error("untrusted load must not include the project-layer Stop hook") + } +} + +func TestLoadMalformedIsWarnedNotFatal(t *testing.T) { + home := t.TempDir() + work := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "hooks.json"), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + merged, warnings := Load(home, work, false) + if len(warnings) == 0 { + t.Error("expected a warning for malformed hooks.json") + } + if !merged.Empty() { + t.Error("malformed file should be skipped, leaving config empty") + } +} + +func TestFirePreToolUseDenyExit2(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("write", cmd("echo blocked >&2; exit 2"))}, + })) + dec := d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write", ToolInput: json.RawMessage(`{"path":"x"}`)}) + if !dec.Denied() { + t.Fatal("expected denied") + } + if dec.Permission != PermDeny { + t.Errorf("permission=%q want deny", dec.Permission) + } + if dec.Reason != "blocked" { + t.Errorf("reason=%q want 'blocked' (from stderr)", dec.Reason) + } +} + +func TestFirePreToolUseAllowStdout(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("write", cmd(`echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'`))}, + })) + dec := d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write"}) + if dec.Denied() { + t.Fatal("allow should not be denied") + } + if dec.Permission != PermAllow { + t.Errorf("permission=%q want allow", dec.Permission) + } +} + +func TestFireUpdatedInput(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("write", cmd(`echo '{"hookSpecificOutput":{"updatedInput":{"path":"safe.txt"}}}'`))}, + })) + dec := d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write", ToolInput: json.RawMessage(`{"path":"danger.txt"}`)}) + if string(dec.UpdatedInput) != `{"path":"safe.txt"}` { + t.Errorf("updatedInput=%s want {\"path\":\"safe.txt\"}", dec.UpdatedInput) + } +} + +func TestFirePostToolUseModifiedResult(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PostToolUse": {group("read", cmd(`echo '{"hookSpecificOutput":{"modifiedResult":"REDACTED"}}'`))}, + })) + dec := d.Fire(context.Background(), PostToolUse, Payload{ToolName: "read", ToolResponse: "secret"}) + if dec.ModifiedResult == nil || *dec.ModifiedResult != "REDACTED" { + t.Errorf("modifiedResult=%v want REDACTED", dec.ModifiedResult) + } +} + +func TestFireMatcherMiss(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("read", cmd("exit 2"))}, + })) + dec := d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write"}) + if dec.Denied() { + t.Error("matcher 'read' must not match tool 'write'") + } +} + +func TestFireFoldDenyWins(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": { + group("write", cmd("exit 0")), // allow/neutral + group("write", cmd("echo no >&2; exit 2")), // deny + }, + })) + dec := d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write"}) + if !dec.Denied() { + t.Error("any deny must fold to denied") + } +} + +func TestFireDenyShortCircuits(t *testing.T) { + d, dir := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": { + group("write", cmd("exit 2")), // deny first + group("write", cmd("echo ran > sentinel.txt")), // must NOT run + }, + })) + d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write"}) + if _, err := os.Stat(filepath.Join(dir, "sentinel.txt")); err == nil { + t.Error("second hook ran despite earlier deny (no short-circuit)") + } +} + +func TestFireTimeoutFailSafe(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("write", HookSpec{Type: "command", Command: "sleep 3", Timeout: 1})}, + })) + start := time.Now() + dec := d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write"}) + if dec.Denied() { + t.Error("timeout must fail-safe to allow, not deny") + } + if elapsed := time.Since(start); elapsed > 2500*time.Millisecond { + t.Errorf("timeout not enforced, took %s", elapsed) + } +} + +func TestFireStopBlockExit2(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "Stop": {group("", cmd("echo tests failing >&2; exit 2"))}, + })) + dec := d.Fire(context.Background(), Stop, Payload{StopHookActive: false}) + if !dec.Block { + t.Error("Stop hook exit 2 should block (force continue)") + } + if dec.Reason != "tests failing" { + t.Errorf("reason=%q", dec.Reason) + } +} + +func TestFireStopBlockContinueFalse(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "Stop": {group("", cmd(`echo '{"continue":false,"reason":"finish tests"}'`))}, + })) + dec := d.Fire(context.Background(), Stop, Payload{}) + if !dec.Block { + t.Error("continue:false should block") + } + if dec.Reason != "finish tests" { + t.Errorf("reason=%q want 'finish tests'", dec.Reason) + } +} + +func TestFireNonBlockableExit2Ignored(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PostToolUse": {group("read", cmd("exit 2"))}, + })) + dec := d.Fire(context.Background(), PostToolUse, Payload{ToolName: "read"}) + if dec.Denied() || dec.Block { + t.Error("PostToolUse is non-blockable; exit 2 must not block") + } +} + +func TestFireStdinPayloadDelivered(t *testing.T) { + d, dir := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("write", cmd("cat > payload.json"))}, + })) + d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write", ToolInput: json.RawMessage(`{"path":"a"}`)}) + data, err := os.ReadFile(filepath.Join(dir, "payload.json")) + if err != nil { + t.Fatal(err) + } + var p Payload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("stdin was not valid Payload JSON: %v", err) + } + if p.ToolName != "write" || p.HookEventName != "PreToolUse" { + t.Errorf("payload mismatch: tool=%q event=%q", p.ToolName, p.HookEventName) + } +} + +func TestFireEnvVars(t *testing.T) { + d, dir := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("write", cmd(`printf '%s' "$JCODE_TOOL_NAME" > toolname`))}, + })) + d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write"}) + data, err := os.ReadFile(filepath.Join(dir, "toolname")) + if err != nil { + t.Fatal(err) + } + if string(data) != "write" { + t.Errorf("JCODE_TOOL_NAME=%q want write", data) + } +} + +func TestNopDispatcherWhenEmpty(t *testing.T) { + d := NewDispatcher(Config{}, Options{}) + if d.Configured(PreToolUse) { + t.Error("empty config should report not configured") + } + dec := d.Fire(context.Background(), PreToolUse, Payload{ToolName: "write"}) + if dec.Denied() || dec.Block || dec.UpdatedInput != nil { + t.Error("nop dispatcher must return zero Decision") + } +} + +func TestParentCancelAborts(t *testing.T) { + d, _ := newTestDispatcher(t, cfg(map[string][]HookGroup{ + "PreToolUse": {group("write", cmd("exit 2"))}, + })) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + dec := d.Fire(ctx, PreToolUse, Payload{ToolName: "write"}) + if dec.Denied() { + t.Error("cancelled ctx should abort hook (no-op), not surface a deny") + } +} diff --git a/internal/hooks/match.go b/internal/hooks/match.go new file mode 100644 index 0000000..3bba340 --- /dev/null +++ b/internal/hooks/match.go @@ -0,0 +1,97 @@ +package hooks + +import ( + "regexp" + "strings" + "sync" +) + +// toolAliases maps a real jcode tool name to alternative names it should also +// match. This lets a config written with Claude Code's tool names (Bash, Write, +// Edit, Read, …) match jcode's actual tools (execute, write, edit, read, …), so +// users can reuse hook configs across tools. +var toolAliases = map[string][]string{ + "execute": {"Bash", "run_shell"}, + "write": {"Write"}, + "edit": {"Edit"}, + "read": {"Read"}, + "glob": {"Glob"}, + "grep": {"Grep"}, +} + +// matchCandidates returns the set of names a matcher is tested against for a +// given tool: its real name plus any aliases. +func matchCandidates(toolName string) []string { + if toolName == "" { + return []string{""} + } + names := []string{toolName} + names = append(names, toolAliases[toolName]...) + return names +} + +var ( + reCacheMu sync.Mutex + reCache = map[string]*regexp.Regexp{} +) + +// compileMatcher compiles (and caches) a matcher pattern. Invalid regexes return +// nil, which callers treat as "no match" rather than crashing. +func compileMatcher(pattern string) *regexp.Regexp { + reCacheMu.Lock() + defer reCacheMu.Unlock() + if re, ok := reCache[pattern]; ok { + return re + } + re, err := regexp.Compile(pattern) + if err != nil { + re = nil + } + reCache[pattern] = re + return re +} + +// regexMeta are the characters that make a matcher part a regexp rather than a +// literal tool name. +const regexMeta = `.^$*+?()[]{}\|` + +// matchesTool reports whether a group's matcher applies to the given tool. +// +// - empty or "*" matches every tool (and non-tool events). +// - otherwise the matcher is split on "|" into parts. A part with no regex +// metacharacters is matched EXACTLY against the tool name and its aliases, so +// "write" hooks the write tool and NOT todowrite/overwrite. A part containing +// metacharacters is treated as a regexp (e.g. "mcp__.*", "^execute$"). +// +// This exact-by-default rule avoids the classic footgun where an unanchored +// "write" regexp silently also matches the high-frequency todowrite tool. +func matchesTool(matcher, toolName string) bool { + if matcher == "" || matcher == "*" { + return true + } + candidates := matchCandidates(toolName) + for _, part := range strings.Split(matcher, "|") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if strings.ContainsAny(part, regexMeta) { + re := compileMatcher(part) + if re == nil { + continue // invalid regex → no match, no panic + } + for _, name := range candidates { + if re.MatchString(name) { + return true + } + } + } else { + for _, name := range candidates { + if name == part { + return true + } + } + } + } + return false +} diff --git a/internal/hooks/proc_other.go b/internal/hooks/proc_other.go new file mode 100644 index 0000000..ae5c9f4 --- /dev/null +++ b/internal/hooks/proc_other.go @@ -0,0 +1,10 @@ +//go:build windows + +package hooks + +import "os/exec" + +// setupProcessGroup is a no-op on platforms without POSIX process groups. The +// default CommandContext cancel plus cmd.WaitDelay still bound cmd.Wait so the +// agent is never blocked; a spawned child may briefly outlive the timeout. +func setupProcessGroup(cmd *exec.Cmd) {} diff --git a/internal/hooks/proc_unix.go b/internal/hooks/proc_unix.go new file mode 100644 index 0000000..60a9c33 --- /dev/null +++ b/internal/hooks/proc_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package hooks + +import ( + "os/exec" + "syscall" +) + +// setupProcessGroup runs the hook shell in its own process group and makes +// timeout/cancel kill the whole group, so a grandchild the shell spawned (e.g. a +// `sleep`) is torn down instead of surviving as an orphan. +func setupProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + // Negative pid signals the whole process group (leader pid == group id). + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } +} diff --git a/internal/hooks/types.go b/internal/hooks/types.go new file mode 100644 index 0000000..fc997d0 --- /dev/null +++ b/internal/hooks/types.go @@ -0,0 +1,125 @@ +// Package hooks implements jcode's user-configurable hook system: external +// commands fired at key points of the agent loop (before/after a tool runs, on +// session start, when the agent is about to stop, etc.). +// +// The package is a dependency-free leaf: internal/agent, internal/runner and the +// command surfaces depend on it, never the other way around. This keeps the hook +// dispatcher transport-agnostic so TUI, Web and ACP all share one implementation. +// +// The on-disk schema and the stdin/stdout protocol mirror Claude Code / Qoder so +// users can reuse familiar configs. See internal-doc/hooks-design.md. +package hooks + +import "encoding/json" + +// Event is a hook trigger point. +type Event string + +const ( + SessionStart Event = "SessionStart" + UserPromptSubmit Event = "UserPromptSubmit" + PreToolUse Event = "PreToolUse" + PostToolUse Event = "PostToolUse" + PostToolUseFailure Event = "PostToolUseFailure" + Stop Event = "Stop" + // NOTE: PreCompact/PostCompact are intentionally not defined yet — they are a + // documented follow-up (see internal-doc/hooks-design.md §11). Only add the + // constants once the compaction middleware actually fires them, so the code + // and the user docs never advertise an event that never runs. +) + +// Blockable reports whether an event's hooks may block/deny (via exit code 2 or a +// structured decision). Non-blockable events can still inject context or modify +// results, but cannot halt the operation. +func (e Event) Blockable() bool { + switch e { + case UserPromptSubmit, PreToolUse, Stop: + return true + } + return false +} + +// Permission is a PreToolUse decision. +type Permission string + +const ( + PermNone Permission = "" // hook expressed no opinion + PermAllow Permission = "allow" + PermDeny Permission = "deny" + PermAsk Permission = "ask" +) + +// Payload is the JSON handed to a hook process over stdin. Field names match the +// Claude Code contract so existing hook scripts work unchanged. +type Payload struct { + SessionID string `json:"session_id,omitempty"` + TranscriptPath string `json:"transcript_path,omitempty"` + CWD string `json:"cwd,omitempty"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name,omitempty"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + ToolResponse string `json:"tool_response,omitempty"` + Prompt string `json:"prompt,omitempty"` + StopHookActive bool `json:"stop_hook_active,omitempty"` +} + +// Decision is the folded outcome of firing all of an event's matching hooks. +// The zero value means "no hook had any opinion — proceed normally". +type Decision struct { + // Permission is the folded PreToolUse verdict. PermDeny wins over everything. + Permission Permission + // Block is true when a blockable event decided to halt (exit 2 / decision=block). + Block bool + // Reason explains a deny/block, surfaced to the agent or user. + Reason string + // UpdatedInput, if non-nil, replaces tool_input before execution (PreToolUse). + UpdatedInput json.RawMessage + // ModifiedResult, if non-nil, replaces tool_response after execution (PostToolUse). + ModifiedResult *string + // AdditionalContext is text to inject into the model context. + AdditionalContext string + // SystemMessage is a non-blocking note surfaced to the user. + SystemMessage string +} + +// Denied reports whether the decision blocks the operation. +func (d Decision) Denied() bool { return d.Block || d.Permission == PermDeny } + +// hookSpecificOutput mirrors the nested stdout JSON object a hook may print. +type hookSpecificOutput struct { + HookEventName string `json:"hookEventName,omitempty"` + PermissionDecision string `json:"permissionDecision,omitempty"` // allow|deny|ask + PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"` + UpdatedInput json.RawMessage `json:"updatedInput,omitempty"` + ModifiedResult *string `json:"modifiedResult,omitempty"` + AdditionalContext string `json:"additionalContext,omitempty"` +} + +// hookOutput is the full stdout JSON envelope (parsed only on exit code 0). +type hookOutput struct { + Continue *bool `json:"continue,omitempty"` // false → block (Stop/UserPromptSubmit) + Decision string `json:"decision,omitempty"` // "block" | "approve" + Reason string `json:"reason,omitempty"` + SystemMessage string `json:"systemMessage,omitempty"` + SuppressOutput bool `json:"suppressOutput,omitempty"` + HookSpecificOutput *hookSpecificOutput `json:"hookSpecificOutput,omitempty"` +} + +// HookSpec is a single configured hook command. +type HookSpec struct { + Type string `json:"type"` // v1 supports "command" + Command string `json:"command"` // shell command (run via `sh -c`) + Timeout int `json:"timeout,omitempty"` // seconds; 0 → dispatcher default + Async bool `json:"async,omitempty"` // fire-and-forget (non-blockable events only) +} + +// HookGroup binds a matcher to a set of hook commands. +type HookGroup struct { + Matcher string `json:"matcher,omitempty"` + Hooks []HookSpec `json:"hooks"` +} + +// Config is one parsed hooks.json file: event name → matcher groups. +type Config struct { + Hooks map[string][]HookGroup `json:"hooks"` +} diff --git a/internal/runner/approval.go b/internal/runner/approval.go index 8f3fe4d..30474b4 100644 --- a/internal/runner/approval.go +++ b/internal/runner/approval.go @@ -10,6 +10,7 @@ import ( "sync" "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/hooks" "github.com/cnjack/jcode/internal/mode" ) @@ -357,6 +358,14 @@ func originFromArgs(toolArgs, key string) string { // It returns true immediately for read-only or obviously safe commands. // For everything else it sends a TUI prompt and waits for the user's answer. func (s *ApprovalState) RequestApproval(ctx context.Context, toolName, toolArgs string) (bool, error) { + // A PreToolUse hook that returned permissionDecision=allow pre-authorizes this + // specific call, so the user is not prompted. This is scoped to the single + // invocation whose ctx carries the flag. + if hooks.IsPreApproved(ctx) { + s.notifyToolInProgress(toolName, toolArgs) + return true, nil + } + // State machine: AUTO mode passes all operations directly. s.mu.Lock() currentMode := s.mode diff --git a/internal/runner/continuation_test.go b/internal/runner/continuation_test.go new file mode 100644 index 0000000..caa5294 --- /dev/null +++ b/internal/runner/continuation_test.go @@ -0,0 +1,57 @@ +package runner + +import "testing" + +func TestContinuationSource(t *testing.T) { + const todoCapConst = 3 + cases := []struct { + name string + todoIncomplete bool + todoUsed int + goalActive bool + stopBlock bool + want string + }{ + {"todo wins over goal and stop", true, 0, true, true, "todo"}, + {"todo within cap", true, 2, false, false, "todo"}, + {"todo exhausted falls to goal", true, todoCapConst, true, false, "goal"}, + {"todo exhausted falls to stop", true, todoCapConst, false, true, "stop"}, + {"goal beats stop", false, 0, true, true, "goal"}, + {"stop only", false, 0, false, true, "stop"}, + {"nothing continues", false, 0, false, false, ""}, + {"todo cap zero skips todo", true, 0, false, true, "stop"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + todoCap := todoCapConst + if c.name == "todo cap zero skips todo" { + todoCap = 0 + } + got := continuationSource(c.todoIncomplete, c.todoUsed, todoCap, c.goalActive, c.stopBlock) + if got != c.want { + t.Errorf("continuationSource=%q want %q", got, c.want) + } + }) + } +} + +// TestContinuationBounded proves the loop is bounded: repeatedly asking for the +// next source while incrementing todoUsed converges (todo eventually yields to +// the umbrella budget rather than looping forever). +func TestContinuationBounded(t *testing.T) { + todoUsed := 0 + laps := 0 + for i := 0; i < 25; i++ { // umbrella budget + src := continuationSource(true /*todo always incomplete*/, todoUsed, 3, false, false) + if src == "" { + break + } + if src == "todo" { + todoUsed++ + } + laps++ + } + if laps != 3 { + t.Errorf("todo-only continuation ran %d laps, want 3 (sub-cap), then stops", laps) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 99add5d..4e8d67b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -11,6 +11,7 @@ import ( "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/hooks" internalmodel "github.com/cnjack/jcode/internal/model" "github.com/cnjack/jcode/internal/session" "github.com/cnjack/jcode/internal/telemetry" @@ -72,28 +73,77 @@ func Run( // so earlier turns are not duplicated into the context. pending := resp - // Completion guard: if the agent finished but there are still incomplete - // todos, re-run with a reminder so nothing is left behind. - const maxGuardRetries = 3 -todoLoop: - for i := 0; i < maxGuardRetries; i++ { - // Respect cancellation (e.g. user stop): a canceled context must not - // drive the auto-continue path, mirroring the goal loop below. Without - // this, a stop floods the chat with paired "Incomplete todos - // detected" / "context canceled" messages for every retry. + // Unified continuation pipeline. Three mechanisms can keep the agent going + // after it stops calling tools — incomplete-todo guard, active-goal guard, and + // a user-configured Stop hook. They share ONE loop, ONE umbrella budget, and + // ONE cancellation check so they can't compound into an unbounded run or + // silently bypass each other. Precedence: todo → goal → Stop hook. The Stop + // hook only fires once the internal guards are quiet (the agent would truly + // stop), and carries stop_hook_active so a script can stop forcing laps. + const ( + maxTodoRetries = 3 // sub-cap so incomplete todos can't hog the budget + // Umbrella budget shared by todo/goal/Stop. Sized as the goal's original + // 25 plus the todo sub-cap of 3, so merging the loops does not shrink the + // goal's effective continuation budget. + maxContinuations = 25 + maxTodoRetries + ) + disp := hooks.DispatcherFromContext(ctx) + todoUsed := 0 + stopHookActive := false +continuationLoop: + for i := 0; i < maxContinuations; i++ { + // User cancellation is a one-vote veto over every continuation mechanism. select { case <-ctx.Done(): - config.Logger().Printf("[runner] todo continuation cancelled") - break todoLoop + config.Logger().Printf("[runner] continuation cancelled") + break continuationLoop default: } - if todoStore == nil || !todoStore.HasIncomplete() { - break + if goalStore != nil && tokenUsage != nil { + goalStore.RecordTokens(tokenUsage.GetLastTotal()) + } + + todoIncomplete := todoStore != nil && todoStore.HasIncomplete() + goalPrompt := "" + if goalStore != nil && goalStore.IsActive() { + goalPrompt = goalStore.ContinuationPrompt() + } + goalActive := goalPrompt != "" + + // Only consult the Stop hook when the internal guards are quiet, so it sees + // the true end of the turn rather than a half-finished state. + todoWantsMore := todoIncomplete && todoUsed < maxTodoRetries + var stopBlock bool + var stopReason string + if !todoWantsMore && !goalActive && disp.Configured(hooks.Stop) { + dec := disp.Fire(ctx, hooks.Stop, hooks.Payload{StopHookActive: stopHookActive}) + stopBlock = dec.Block + stopReason = dec.Reason + } + + var reason, banner string + switch continuationSource(todoIncomplete, todoUsed, maxTodoRetries, goalActive, stopBlock) { + case "todo": + reason = todoStore.IncompleteSummary() + banner = "\n⚠️ Incomplete todos detected, continuing...\n" + todoUsed++ + case "goal": + reason = goalPrompt + banner = "\n🎯 Goal active — continuing toward objective...\n" + case "stop": + reason = stopReason + if reason == "" { + reason = "A Stop hook requested that you keep working before finishing." + } + banner = "\n🛑 Stop hook active — continuing...\n" + stopHookActive = true + default: + break continuationLoop // nothing wants to continue → truly done } - reminder := todoStore.IncompleteSummary() - h.OnAgentText("\n⚠️ Incomplete todos detected, continuing...\n") + + h.OnAgentText(banner) messages = append(messages, &schema.Message{Role: schema.Assistant, Content: pending}) - messages = append(messages, schema.UserMessage(reminder)) + messages = append(messages, schema.UserMessage(reason)) extra, done := runInner(ctx, ag, messages, h, rec) resp += extra pending = extra @@ -102,44 +152,6 @@ todoLoop: } } - // Goal continuation guard: if an active goal exists and the agent stopped - // without proving it complete, keep injecting a continuation prompt and - // re-running — mirroring codex's idle auto-continuation. Bounded by a hard - // turn cap and context cancellation. - if goalStore != nil { - const maxGoalContinuations = 25 - goalLoop: - for turns := 0; turns < maxGoalContinuations; turns++ { - select { - case <-ctx.Done(): - config.Logger().Printf("[runner] goal continuation cancelled") - break goalLoop - default: - } - // Record observed tokens for the informational usage display. - if tokenUsage != nil { - goalStore.RecordTokens(tokenUsage.GetLastTotal()) - } - if !goalStore.IsActive() { - break - } - cont := goalStore.ContinuationPrompt() - if cont == "" { - break - } - config.Logger().Printf("[runner] goal continuation #%d", turns+1) - h.OnAgentText("\n🎯 Goal active — continuing toward objective...\n") - messages = append(messages, &schema.Message{Role: schema.Assistant, Content: pending}) - messages = append(messages, schema.UserMessage(cont)) - extra, done := runInner(ctx, ag, messages, h, rec) - resp += extra - pending = extra - if done { - return resp - } - } - } - // Send a final token usage update before signalling done. Prefer the // context-local tracker (per-agent) and fall back to the passed-in one. tracker := tokenUsage @@ -154,6 +166,23 @@ todoLoop: return resp } +// continuationSource picks which mechanism drives the next continuation lap, +// encoding the precedence todo → goal → Stop hook and the todo sub-cap. It is a +// pure function so the precedence and bounding are unit-testable without a live +// agent. Returns "" when nothing should continue (the turn is truly done). +func continuationSource(todoIncomplete bool, todoUsed, todoCap int, goalActive, stopBlock bool) string { + if todoIncomplete && todoUsed < todoCap { + return "todo" + } + if goalActive { + return "goal" + } + if stopBlock { + return "stop" + } + return "" +} + func runInner( ctx context.Context, ag *adk.ChatModelAgent, diff --git a/internal/web/server.go b/internal/web/server.go index e04e931..7fa74a8 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -29,6 +29,7 @@ import ( "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/hooks" "github.com/cnjack/jcode/internal/mode" "github.com/cnjack/jcode/internal/model" "github.com/cnjack/jcode/internal/runner" @@ -823,7 +824,10 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str // Take a git snapshot before the agent run for session diff tracking. s.takeSessionSnapshot(eng) - resp := runner.Run(runCtx, agent, history, eng.eventHandler, recorder, eng.todoStore, eng.env.GoalStore, s.tracer, eng.tokenUsage) + // Inject the hook dispatcher so PreToolUse/PostToolUse/Stop hooks run on the + // Web surface too (parity with the TUI); reloaded per turn for hot-apply. + hookCtx := hooks.WithDispatcher(runCtx, hooks.NewSessionDispatcher(config.ConfigDir(), eng.env.Pwd(), recorder.UUID(), config.Logger().Printf)) + resp := runner.Run(hookCtx, agent, history, eng.eventHandler, recorder, eng.todoStore, eng.env.GoalStore, s.tracer, eng.tokenUsage) if resp != "" { eng.emu.Lock() eng.history = append(eng.history, &schema.Message{Role: schema.Assistant, Content: resp}) diff --git a/site/docs/overview/hooks.md b/site/docs/overview/hooks.md new file mode 100644 index 0000000..493b7ff --- /dev/null +++ b/site/docs/overview/hooks.md @@ -0,0 +1,337 @@ +--- +title: Hooks +parent: Overview +nav_order: 14 +--- + +# Hooks + +Hooks let you run your own commands at key points in the agent loop — before a +tool runs, after it finishes, when you submit a prompt, or when the agent is +about to stop. Unlike an instruction in a prompt (which the model *may* follow), +a hook runs **deterministically** every time its event fires. Use them to block +dangerous commands, auto-format files, gate on tests, redact secrets, or get +pinged when a long task finishes. + +{: .note } +> Hooks are plain programs. jcode passes each one a small JSON object on stdin +> and reads its exit code (and optional JSON on stdout). Any language works — +> shell, Python, Node, a compiled binary. + +## Quick start + +Auto-format Go files the moment the agent writes or edits one. Create +`~/.jcode/hooks.json`: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "write|edit", + "hooks": [ + { "type": "command", "command": "f=$(jq -r .tool_input.file_path); case \"$f\" in *.go) gofmt -w \"$f\";; esac" } + ] + } + ] + } +} +``` + +That's it — start jcode and every file the agent writes stays gofmt-clean. No +restart needed for later edits to `hooks.json`; the config is re-read each turn. + +## Events + +Six events fire during a run. Three of them are **blockable** — the hook can +stop the action. + +| Event | Fires | Blockable | Typical use | +|---|---|---|---| +| `SessionStart` | when a session begins | no | inject project conventions | +| `UserPromptSubmit` | after you send a message, before the model sees it | **yes** | rewrite/augment or reject prompts | +| `PreToolUse` | before a tool runs | **yes** | block, rewrite args, or auto-approve | +| `PostToolUse` | after a tool succeeds | no | format, lint, redact, log | +| `PostToolUseFailure` | after a tool returns an error | no | log failures, notify, add retry hints | +| `Stop` | when the agent is about to finish | **yes** | quality gate, "ping me when done" | + +## Where hooks live + +Hooks are configured in a `hooks.json` file: + +- **`~/.jcode/hooks.json`** — your personal hooks, loaded on every project. This + is the recommended place. +- **`.jcode/hooks.json`** and **`.jcode/hooks.local.json`** — project hooks, + checked into (or ignored by) the repo. + +{: .warning } +> **Project hooks are disabled by default.** A hook runs arbitrary commands the +> instant its event fires — a `SessionStart` hook in a cloned repo would execute +> the moment you open jcode there. So jcode only loads `~/.jcode/hooks.json` +> unless you explicitly opt in per shell with +> `export JCODE_HOOKS_TRUST_PROJECT=1`. Only enable it for repositories you +> trust. Put your own hooks in `~/.jcode/hooks.json` and this never bites you. + +When multiple layers are enabled, all of their matching hooks run (they add up, +they don't override each other). + +## Configuration format + +```json +{ + "hooks": { + "": [ + { + "matcher": "", + "hooks": [ + { "type": "command", "command": "", "timeout": 30 } + ] + } + ] + } +} +``` + +- `matcher` scopes the group to certain tools (see [Matchers](#matchers)). Omit + it (or use `"*"`) to match every tool. It's ignored by events that have no + tool (`SessionStart`, `UserPromptSubmit`, `Stop`). +- `command` runs via `sh -c`, so pipes and `&&` work. +- `timeout` is in **seconds** (default 60). A hook that times out is treated as + "allow" and never blocks the agent. + +## The hook contract + +**Input (stdin).** Every hook receives a JSON object. Fields depend on the event: + +```json +{ + "hook_event_name": "PreToolUse", + "session_id": "…", + "cwd": "/path/to/project", + "tool_name": "execute", + "tool_input": { "command": "rm -rf build" }, + "tool_response": "…", + "prompt": "…", + "stop_hook_active": false +} +``` + +`tool_name`/`tool_input` are present for tool events, `tool_response` for +`PostToolUse*`, `prompt` for `UserPromptSubmit`, `stop_hook_active` for `Stop`. +The same values are also exported as environment variables — +`JCODE_TOOL_NAME`, `JCODE_CWD`, `JCODE_SESSION_ID`, `JCODE_HOOK_EVENT` — so a +simple hook can skip parsing stdin. + +**Output (exit code).** + +| Exit code | Meaning | +|---|---| +| `0` | allow — and parse stdout JSON if present | +| `2` | **block** (blockable events only); stderr is fed back to the agent as the reason | +| other | non-blocking error; stderr is logged, the action proceeds | + +**Output (stdout JSON, on exit 0).** For fine-grained control, print a JSON +object: + +```json +{ + "hookSpecificOutput": { + "permissionDecision": "allow", + "permissionDecisionReason": "read-only tool", + "updatedInput": { "file_path": "safe.txt" }, + "modifiedResult": "…scrubbed output…", + "additionalContext": "extra context for the model" + } +} +``` + +- `permissionDecision` (PreToolUse): `allow` skips the approval prompt, `deny` + blocks, `ask` falls through to normal approval. +- `updatedInput` (PreToolUse): replaces the tool's arguments before it runs. +- `modifiedResult` (PostToolUse): replaces what the model sees as the result. +- `additionalContext`: extra text handed to the model. + +For `Stop`, a top-level `{ "continue": false, "reason": "…" }` forces the agent +to keep working instead of stopping. + +## Matchers + +A matcher is **exact by default**, split on `|`: + +- `"write"` matches only the `write` tool — not `todowrite`. +- `"write|edit"` matches `write` or `edit`. +- A part containing regex characters is a regex: `"mcp__.*"` matches every MCP + tool, `"^execute$"` is an anchored exact match. + +jcode's built-in tools are `read`, `write`, `edit`, `execute` (shell), `grep`, +`glob`. For convenience the Claude Code names — `Read`, `Write`, `Edit`, `Bash`, +`Grep`, `Glob` — match the same tools, so configs written for Claude Code work +unchanged. + +## Use cases + +The examples below use inline commands for brevity. For anything longer, point +`command` at a script (e.g. `~/.jcode/hooks/guard.sh`) and keep the logic there. + +### Block dangerous commands + +Stop the agent from running destructive shell commands, no matter how it phrases +them: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "execute", + "hooks": [ + { "type": "command", "command": "jq -r .tool_input.command | grep -Eq 'rm -rf|git push .*--force|DROP TABLE' && { echo 'blocked by policy' >&2; exit 2; } || exit 0" } + ] + } + ] + } +} +``` + +### Format on save + +Run a formatter after every write/edit (see [Quick start](#quick-start)). Swap +`gofmt` for `prettier --write "$f"`, `ruff format "$f"`, etc. + +### Test gate before finishing + +Don't let the agent stop until the build and tests pass: + +```json +{ + "hooks": { + "Stop": [ + { "hooks": [ { "type": "command", "command": "~/.jcode/hooks/test-gate.sh", "timeout": 300 } ] } + ] + } +} +``` + +```bash +#!/usr/bin/env bash +# ~/.jcode/hooks/test-gate.sh +input=$(cat) +# Prevent an infinite loop: if we already forced a continue once, let it stop. +[ "$(echo "$input" | jq -r .stop_hook_active)" = "true" ] && exit 0 +if ! go build ./... 2>&1; then + echo "build is broken — fix it before finishing" >&2 + exit 2 +fi +if ! go test ./... 2>&1; then + echo "tests are failing — make them pass before finishing" >&2 + exit 2 +fi +exit 0 +``` + +{: .important } +> A `Stop` hook that blocks makes the agent keep going, which fires `Stop` again. +> Always check `stop_hook_active` and `exit 0` when it's `true`, or you'll loop. + +### Auto-approve read-only tools + +Skip the approval prompt for tools that can't change anything: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "read|grep|glob", + "hooks": [ + { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"allow\"}}'" } + ] + } + ] + } +} +``` + +### Redact secrets from tool output + +Scrub anything that looks like a key before the model ever sees it: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "read|execute", + "hooks": [ + { "type": "command", "command": "jq -r .tool_response | sed -E 's/(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,})/[REDACTED]/g' | jq -Rs '{hookSpecificOutput:{modifiedResult:.}}'" } + ] + } + ] + } +} +``` + +### Inject project conventions every session + +Teach the agent your project's rules without repeating them in every prompt: + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"additionalContext\":\"Use pnpm, never npm. Target Go 1.26. Run gofmt before committing.\"}}'" } + ] + } + ] + } +} +``` + +`UserPromptSubmit` works the same way if you'd rather attach context (like the +current git branch) to every message. + +### Ping me when the agent finishes + +Get a desktop notification when a long run wraps up (macOS): + +```json +{ + "hooks": { + "Stop": [ + { "hooks": [ { "type": "command", "command": "osascript -e 'display notification \"jcode is done\" with title \"jcode\"'" } ] } + ] + } +} +``` + +### Audit every tool call + +Append a log of everything the agent does: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { "type": "command", "command": "jq -c '{t: now, tool: .tool_name, input: .tool_input}' >> \"$JCODE_CWD/.jcode-audit.log\"" } + ] + } + ] + } +} +``` + +## Safety & gotchas + +- **Fail-safe by design.** A hook that times out, crashes, or returns an + unexpected exit code never blocks the agent — it's treated as "allow" and + logged. Only a clean `exit 2` blocks. +- **`Stop` loops.** Always honor `stop_hook_active` (see the test-gate example). +- **Order & precedence.** For one event, all matching hooks run; if any of them + denies, the action is blocked. `PreToolUse` runs before approval, so a `deny` + never even reaches the prompt. +- **Keep them fast.** Hooks run inline on the tool path. Heavy work belongs in a + background job the hook kicks off, not in the hook itself.