From 5a3b4a918dd636a8efd15dcaa61572f5fc6e7639 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 5 Jul 2026 09:54:00 +0800 Subject: [PATCH 1/3] feat(hooks): user-configurable lifecycle hooks (external command, 3-surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a hook system that runs user-configured external commands at key points of the agent loop, mirroring the Claude Code / codex model (JSON over stdin/stdout, exit-code control, matcher scoping). Events: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Stop. Design (see internal-doc/hooks-design.md): a transport-agnostic internal/hooks.Dispatcher is injected via context, so TUI, Web and ACP all share one implementation. Tool hooks mount as two eino middlewares around the approval gate (pre outside so PreToolUse can deny / rewrite args / pre-approve; post inside so it sees the real tool error). The runner's todo/goal auto-continuation loops merge with the Stop hook into a single continuation pipeline with one umbrella budget, one cancellation check, and stop_hook_active loop prevention. Security: only the user layer (~/.jcode/hooks.json) loads by default; project layers (.jcode/hooks.json[.local]) require JCODE_HOOKS_TRUST_PROJECT=1, since a hook is arbitrary code execution the moment its event fires — closing a clone-and-run RCE / approval-bypass vector. Trust-on-first-use is the follow-up. Matchers are exact-by-default (so "write" doesn't also hit todowrite); regex opts in via metacharacters. Fail-safe throughout: timeout/crash/bad exit never blocks the agent. Tested: 28 unit/integration tests (dispatcher, middleware, continuation, matcher, config); 2 real-LLM agent-eval cases; public docs at site/docs/overview/hooks.md with verified use-case snippets. Generated with Jack AI bot --- agent-eval/suite/testcases.json | 48 ++- internal-doc/hooks-design.md | 489 +++++++++++++++++++++++++ internal/agent/agent.go | 10 +- internal/agent/hook_middleware.go | 125 +++++++ internal/agent/hook_middleware_test.go | 207 +++++++++++ internal/command/acp.go | 4 + internal/command/interactive.go | 50 +++ internal/hooks/config.go | 101 +++++ internal/hooks/context.go | 44 +++ internal/hooks/dispatcher.go | 202 ++++++++++ internal/hooks/exec.go | 182 +++++++++ internal/hooks/hooks_test.go | 304 +++++++++++++++ internal/hooks/match.go | 97 +++++ internal/hooks/types.go | 124 +++++++ internal/runner/approval.go | 9 + internal/runner/continuation_test.go | 57 +++ internal/runner/runner.go | 137 ++++--- internal/web/server.go | 6 +- site/docs/overview/hooks.md | 337 +++++++++++++++++ 19 files changed, 2475 insertions(+), 58 deletions(-) create mode 100644 internal-doc/hooks-design.md create mode 100644 internal/agent/hook_middleware.go create mode 100644 internal/agent/hook_middleware_test.go create mode 100644 internal/hooks/config.go create mode 100644 internal/hooks/context.go create mode 100644 internal/hooks/dispatcher.go create mode 100644 internal/hooks/exec.go create mode 100644 internal/hooks/hooks_test.go create mode 100644 internal/hooks/match.go create mode 100644 internal/hooks/types.go create mode 100644 internal/runner/continuation_test.go create mode 100644 site/docs/overview/hooks.md 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..efbae87 --- /dev/null +++ b/internal-doc/hooks-design.md @@ -0,0 +1,489 @@ +# 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), +使其包住「审批 + 执行」: + +``` +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 的形状,合并成 +**一个续跑循环 + 一个决策聚合点**: + +``` +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/config.json` 的 `hooks` 键 —— 用户级(最低优先级) + 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 惯用法: + +``` +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 已知边界(后续)**:`Decision.SystemMessage` 仅落日志未 surface 到 UI(M1);PreToolUse 的 +`additionalContext` 拼在结果后而非工具前(M3);SessionStart payload 缺 `model`/`source`(L6); +Pre/PostCompact 事件、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..a0d7d99 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,9 @@ 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. + promptCtx = hooks.WithDispatcher(promptCtx, hooks.NewSessionDispatcher(config.ConfigDir(), sess.env.Pwd(), sess.rec.UUID(), 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..c0c2e46 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,14 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { askUserDeps: askUserDeps, mcpTools: mcpTools, rec: rec, + hookDisp: hookDisp, + } + + // SessionStart hook: fire once; stash any additionalContext to prepend to the + // first prompt. Non-blocking. + if hookDisp.Configured(hooks.SessionStart) { + dec := hookDisp.Fire(ctx, hooks.SessionStart, hooks.Payload{}) + st.hookStartContext = dec.AdditionalContext } // Initialize WeChat channel diff --git a/internal/hooks/config.go b/internal/hooks/config.go new file mode 100644 index 0000000..34e815f --- /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("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..f22d02a --- /dev/null +++ b/internal/hooks/dispatcher.go @@ -0,0 +1,202 @@ +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) { + specs = append(specs, g.Hooks...) + } + } + 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..12a2f3e --- /dev/null +++ b/internal/hooks/exec.go @@ -0,0 +1,182 @@ +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 + +// 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 + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + 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/types.go b/internal/hooks/types.go new file mode 100644 index 0000000..b5528f0 --- /dev/null +++ b/internal/hooks/types.go @@ -0,0 +1,124 @@ +// 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" + PreCompact Event = "PreCompact" + PostCompact Event = "PostCompact" + Stop Event = "Stop" +) + +// 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"` + Trigger string `json:"trigger,omitempty"` // PreCompact/PostCompact reason +} + +// 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..c13239f --- /dev/null +++ b/internal/runner/continuation_test.go @@ -0,0 +1,57 @@ +package runner + +import "testing" + +func TestContinuationSource(t *testing.T) { + const cap = 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, cap, true, false, "goal"}, + {"todo exhausted falls to stop", true, cap, 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 := cap + 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. From 142148864be677b7cd3480d3c264a2a6b8b9b49e Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 5 Jul 2026 10:02:50 +0800 Subject: [PATCH 2/3] fix(hooks): enforce hook timeout with cmd.WaitDelay exec.CommandContext SIGKILLs sh on deadline, but a grandchild it spawned keeps the stdout pipe open, so cmd.Wait blocks until the grandchild exits and defeats the timeout. WaitDelay force-closes the pipes so runHook returns promptly. Fixes TestFireTimeoutFailSafe on Linux CI. Generated with Jack AI bot --- internal/hooks/exec.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/hooks/exec.go b/internal/hooks/exec.go index 12a2f3e..47d198d 100644 --- a/internal/hooks/exec.go +++ b/internal/hooks/exec.go @@ -54,6 +54,11 @@ func runHook(ctx context.Context, spec HookSpec, input []byte, cwd string, env [ var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr + // On timeout/cancel, CommandContext SIGKILLs `sh` but a grandchild (e.g. a + // `sleep` it spawned) can keep the stdout pipe open, which makes cmd.Wait + // block until that grandchild exits — defeating the timeout. WaitDelay bounds + // that wait and force-closes the pipes so runHook returns promptly. + cmd.WaitDelay = 500 * time.Millisecond err := cmd.Run() From fc76b111ad72dd189852a6907fd098a438144e04 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 5 Jul 2026 10:17:40 +0800 Subject: [PATCH 3/3] fix(hooks): address CodeRabbit review (crash guard, resume UUID, hardening) acp: guard optional recorder before sess.rec.UUID() (panic). interactive: rebuild dispatcher + fire SessionStart after resume restores the UUID, gate the setup SessionStart to fresh sessions (avoid double-fire and wrong session_id). dispatcher: skip unsupported hook types. exec: cap stdout/stderr at 1 MiB and run hooks in their own process group (unix) so a timeout kills the whole tree, not just sh. types: drop unwired PreCompact/PostCompact/Trigger so code and docs agree. Plus doc path fix, error prefix, and const rename nits. prompt-level hooks (UserPromptSubmit/SessionStart) remain TUI-only in v1; ACP/Web parity is a documented follow-up. Generated with Jack AI bot --- internal-doc/hooks-design.md | 18 +++++++----- internal/command/acp.go | 7 ++++- internal/command/interactive.go | 17 +++++++++-- internal/hooks/config.go | 2 +- internal/hooks/dispatcher.go | 14 +++++++-- internal/hooks/exec.go | 44 +++++++++++++++++++++++----- internal/hooks/proc_other.go | 10 +++++++ internal/hooks/proc_unix.go | 22 ++++++++++++++ internal/hooks/types.go | 7 +++-- internal/runner/continuation_test.go | 8 ++--- 10 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 internal/hooks/proc_other.go create mode 100644 internal/hooks/proc_unix.go diff --git a/internal-doc/hooks-design.md b/internal-doc/hooks-design.md index efbae87..ff791b9 100644 --- a/internal-doc/hooks-design.md +++ b/internal-doc/hooks-design.md @@ -77,7 +77,7 @@ 放在中间件链里 **`approvalMiddleware` 的外层**(即作为 `handlers` 的最后一个,或在 `agent.go` 里 approval 之前 append), 使其包住「审批 + 执行」: -``` +```text tracing → budget/compaction/reminder → [hookMiddleware] → approvalMiddleware → memory.UsageMiddleware → tool │ PreToolUse │ 审批 gate + endpoint() └─ PostToolUse/Failure ───┘ @@ -170,7 +170,7 @@ hook 脚本自己看 `stop_hook_active` 决定何时放行、外层 `input_queue `for`**;直接再叠一个 Stop hook 会变成三套各自为政、无统一上限的强制续跑。按 codex 的形状,合并成 **一个续跑循环 + 一个决策聚合点**: -``` +```text runInner() // 代理采样到 LLM 不再调工具 for { // 单一续跑循环 if ctx.Done() { break } // 用户取消一票否决(保留现有语义) @@ -215,7 +215,7 @@ h.OnAgentDone(nil) - ~~方案 A:塞进 `~/.jcode/config.json` 的 `hooks` 键~~ —— 已否,缺项目级 / 本地层。 - **方案 B(已采纳)**:独立 `hooks` 配置,三层合并(对齐 Claude Code / Qoder,也贴合 jcode 已有的 `.jcode/` 项目目录): - 1. `~/.jcode/config.json` 的 `hooks` 键 —— 用户级(最低优先级) + 1. `~/.jcode/hooks.json` —— 用户级(最低优先级,默认加载) 2. `.jcode/hooks.json` —— 项目级(可随 git 分享) 3. `.jcode/hooks.local.json` —— 项目本地(`.gitignore`,最高优先级) @@ -345,7 +345,7 @@ stdout 结构化输出(exit 0): 对齐 codex 的四段式,但用 Go 惯用法: -``` +```text internal/hooks/ ├── config.go // HooksConfig 解析 + 三层合并 + 别名表 ├── dispatcher.go // Fire(ctx, event, payload) → Decision;matcher 选择 + 并发执行 + 结果折叠 @@ -476,9 +476,13 @@ UserPromptSubmit / PreToolUse / PostToolUse / PostToolUseFailure / Stop。测试 **审查确认健壮**:续跑无界/死循环(25 硬顶 + todo 子顶 + `ctx.Done()` 一票否决 + `stop_hook_active` 防自锁)、 post hook 拿到真实 err、超时 fail-safe、regex 缓存并发安全、deny 短路不误伤 PostToolUse、pre-approved 不跨会话泄漏。 -**v1 已知边界(后续)**:`Decision.SystemMessage` 仅落日志未 surface 到 UI(M1);PreToolUse 的 -`additionalContext` 拼在结果后而非工具前(M3);SessionStart payload 缺 `model`/`source`(L6); -Pre/PostCompact 事件、trust-on-first-use(hash 信任 UI)、`/hooks` 管理面板、teammate/subagent 的 hook 覆盖。 +**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 覆盖。 --- diff --git a/internal/command/acp.go b/internal/command/acp.go index a0d7d99..361503e 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -672,7 +672,12 @@ func (a *acpAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Pr // 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. - promptCtx = hooks.WithDispatcher(promptCtx, hooks.NewSessionDispatcher(config.ConfigDir(), sess.env.Pwd(), sess.rec.UUID(), config.Logger().Printf)) + // 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 c0c2e46..7a28af5 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -1052,9 +1052,11 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { hookDisp: hookDisp, } - // SessionStart hook: fire once; stash any additionalContext to prepend to the - // first prompt. Non-blocking. - if hookDisp.Configured(hooks.SessionStart) { + // 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 } @@ -1330,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 index 34e815f..1b64433 100644 --- a/internal/hooks/config.go +++ b/internal/hooks/config.go @@ -85,7 +85,7 @@ func loadFile(path string) (Config, error) { } var c Config if err := json.Unmarshal(data, &c); err != nil { - return Config{}, fmt.Errorf("invalid JSON: %w", err) + return Config{}, fmt.Errorf("hooks: invalid JSON: %w", err) } return c, nil } diff --git a/internal/hooks/dispatcher.go b/internal/hooks/dispatcher.go index f22d02a..d4c0404 100644 --- a/internal/hooks/dispatcher.go +++ b/internal/hooks/dispatcher.go @@ -116,8 +116,18 @@ func (d *dispatcher) selectSpecs(event Event, toolName string) []HookSpec { } var specs []HookSpec for _, g := range groups { - if matchesTool(g.Matcher, toolName) { - specs = append(specs, g.Hooks...) + 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 diff --git a/internal/hooks/exec.go b/internal/hooks/exec.go index 47d198d..5ec1fd6 100644 --- a/internal/hooks/exec.go +++ b/internal/hooks/exec.go @@ -20,6 +20,32 @@ const defaultTimeout = 60 * time.Second // 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 @@ -51,13 +77,17 @@ func runHook(ctx context.Context, spec HookSpec, input []byte, cwd string, env [ cmd.Dir = cwd cmd.Stdin = bytes.NewReader(input) cmd.Env = env - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - // On timeout/cancel, CommandContext SIGKILLs `sh` but a grandchild (e.g. a - // `sleep` it spawned) can keep the stdout pipe open, which makes cmd.Wait - // block until that grandchild exits — defeating the timeout. WaitDelay bounds - // that wait and force-closes the pipes so runHook returns promptly. + // 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() 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 index b5528f0..fc997d0 100644 --- a/internal/hooks/types.go +++ b/internal/hooks/types.go @@ -21,9 +21,11 @@ const ( PreToolUse Event = "PreToolUse" PostToolUse Event = "PostToolUse" PostToolUseFailure Event = "PostToolUseFailure" - PreCompact Event = "PreCompact" - PostCompact Event = "PostCompact" 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 @@ -59,7 +61,6 @@ type Payload struct { ToolResponse string `json:"tool_response,omitempty"` Prompt string `json:"prompt,omitempty"` StopHookActive bool `json:"stop_hook_active,omitempty"` - Trigger string `json:"trigger,omitempty"` // PreCompact/PostCompact reason } // Decision is the folded outcome of firing all of an event's matching hooks. diff --git a/internal/runner/continuation_test.go b/internal/runner/continuation_test.go index c13239f..caa5294 100644 --- a/internal/runner/continuation_test.go +++ b/internal/runner/continuation_test.go @@ -3,7 +3,7 @@ package runner import "testing" func TestContinuationSource(t *testing.T) { - const cap = 3 + const todoCapConst = 3 cases := []struct { name string todoIncomplete bool @@ -14,8 +14,8 @@ func TestContinuationSource(t *testing.T) { }{ {"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, cap, true, false, "goal"}, - {"todo exhausted falls to stop", true, cap, false, true, "stop"}, + {"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, ""}, @@ -23,7 +23,7 @@ func TestContinuationSource(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - todoCap := cap + todoCap := todoCapConst if c.name == "todo cap zero skips todo" { todoCap = 0 }