diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 18923d472c..c72b2df44c 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -171,6 +171,12 @@ await api.invoke('your_command', { request: { ... } }); - 不要把硬编码限制或模式判断作为处理 agent loop 循环问题的第一反应,例如仅按字符串或次数阻止重复工具调用。 - 过多硬编码会把 agent loop 变成脆弱的 workflow。应先定位根因:工具行为、模型交互、会话上下文封装、prompt/tool schema 设计,或状态同步问题。 +### Agent Hooks + +- BitFun 实现的是 Codex Hook 契约,因此 是事件、载荷字段与决策结构的参考来源,不要另起炉灶。[`docs/features/agent-hooks.zh-CN.md`](docs/features/agent-hooks.zh-CN.md)([English](docs/features/agent-hooks.md))只覆盖 BitFun 特有部分 —— 文件位置、`app.hooks` 开关和差异表 —— 新增或消除差异时必须同步更新。 +- 可移植引擎(配置解析、载荷构造、进程执行、决策合并)位于 `bitfun-agent-runtime::native_hooks`。`bitfun-core::native_hooks` 负责配置发现、开关门控和按事件的分发辅助函数;各分发点调用这些辅助函数,不要就地执行 Hook。 +- 有三类不同的东西共用 "hook" 一词:本文所述的原生用户 Hooks、内部编译期 `post_call_hooks`,以及其他 AI 应用的只读外部 Hook 目录(`external_hooks`)。三者必须保持区分。 + ## 架构 ### 产品架构护栏 diff --git a/AGENTS.md b/AGENTS.md index abacab1acd..b732db70fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,6 +182,12 @@ await api.invoke('your_command', { request: { ... } }); - Do not add hard-coded limits or pattern checks to the agent loop as a first response to looping behavior, such as blocking repeated tool calls by string or count alone. - Excessive hard-coding turns the agent loop into a brittle workflow engine. Investigate the root cause first: tool behavior, model interaction, session context packaging, prompt/tool schema design, or state synchronization issues. +### Agent hooks + +- BitFun implements the Codex hook contract, so is the reference for events, payload fields, and the decision schema. Do not fork that contract. [`docs/features/agent-hooks.md`](docs/features/agent-hooks.md) ([中文](docs/features/agent-hooks.zh-CN.md)) covers only the BitFun-specific parts — file locations, the `app.hooks` gates, and the deviations table — and must be updated whenever a deviation is added or closed. +- The portable engine (settings parsing, payload construction, process execution, decision merging) lives in `bitfun-agent-runtime::native_hooks`. `bitfun-core::native_hooks` owns config discovery, gating, and per-event dispatch helpers; dispatch sites call those helpers instead of executing hooks inline. +- Three separate things share the word "hook": these native user hooks, the internal compiled-in `post_call_hooks`, and the read-only external hook catalog of other AI applications (`external_hooks`). Keep them separate. + ## Architecture ### Product architecture guardrails diff --git a/README.md b/README.md index 711056ed7c..e8f8685456 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ BitFun's extension paths progress continuously from light to deep customization: | Tier | Path | Best for | | --- | --- | --- | | **L1** | Custom Agent | Defining roles, flows, constraints, and tool bundles. | -| **L2** | MCP / Skills | Connecting external tools, professional capabilities, and workflows. | +| **L2** | MCP / Skills / [Hooks](docs/features/agent-hooks.md) | Connecting external tools and professional capabilities, and running your own commands at Agent lifecycle points — fully Codex-hook compatible, so existing hook scripts work as-is. | | **L3** | Mini App | Generating dedicated interfaces, forms, panels, or visualizations for tasks. | | **L4** | Source-level customization | Changing tools, adapters, UI, Runtime, or product shape. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 14db3dfe50..f8c9c68978 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,7 +111,7 @@ BitFun 的扩展路径从轻到重连续展开: | 层级 | 方式 | 适合场景 | | --- | --- | --- | | **L1** | Agent 自定义 | 定义角色、流程、约束和工具组合。 | -| **L2** | MCP / Skills | 接入外部工具、专业能力和工作流。 | +| **L2** | MCP / Skills / [Hooks](docs/features/agent-hooks.zh-CN.md) | 接入外部工具和专业能力,并在 Agent 生命周期节点运行你自己的命令 —— 完全兼容 Codex Hooks,已有脚本无需适配。 | | **L3** | Mini App | 为任务生成专属界面、表单、面板或可视化。 | | **L4** | 源码级改造 | 修改工具、适配器、UI、Runtime 或产品形态。 | diff --git a/docs/features/agent-hooks.md b/docs/features/agent-hooks.md new file mode 100644 index 0000000000..fc7b17cb22 --- /dev/null +++ b/docs/features/agent-hooks.md @@ -0,0 +1,215 @@ +# Agent hooks + +Hooks let you run your own commands at fixed points in the BitFun Agent's +lifecycle: before and after a tool call, when a permission prompt would appear, +when a prompt is submitted, around context compaction, around subagents, and +when a session or turn starts or ends. A hook can observe what the Agent is +doing, add context the model will read, rewrite a tool call's arguments, or +block an action outright. + +## BitFun hooks are Codex hooks + +BitFun implements **the Codex hook contract**, not a BitFun dialect: + +- the same `hooks.json` document — events, matcher groups, handler fields; +- the same event names (`PreToolUse`, `PostToolUse`, `PermissionRequest`, + `UserPromptSubmit`, `PreCompact`, `PostCompact`, `SessionStart`, + `SessionEnd`, `SubagentStart`, `SubagentStop`, `Stop`); +- the same JSON payload on stdin, with the same field names; +- the same exit-code meanings (`0` success, `2` block with stderr as the + reason, anything else a non-blocking error); +- the same JSON decision schema on stdout (`permissionDecision`, + `updatedInput`, `additionalContext`, `decision`/`reason`, …). + +**A Codex hook script runs in BitFun unchanged, and vice versa — there is +nothing to port.** + +So this page does not restate the reference. For event semantics, the exact +payload fields per event, and the decision schema, use Codex's own +documentation, which covers all of it well: + +**→ ** + +The rest of this page is only what is BitFun-specific: where the files live, +how to switch hooks on, and where BitFun currently differs. + +## Where BitFun reads hooks + +Codex reads `~/.codex/hooks.json`; BitFun reads its own config directory +instead. Everything inside the file is identical. + +| Scope | Path | +| --- | --- | +| User | `/config/hooks.json` | +| Project | `/.bitfun/config/hooks.json` | + +The user config directory is `~/.config/bitfun` on Linux, +`~/Library/Application Support/bitfun` on macOS, and `%APPDATA%\bitfun` on +Windows. + +Both layers are additive: every matching handler runs, user handlers first. +There is no override or shadowing between them. Changes are picked up without +restarting BitFun. + +## Turning hooks on + +**Settings → Agent Hooks**, or directly under the `app` section of +`/config/app.json`: + +```json +{ + "app": { + "hooks": { + "enabled": true, + "project_hooks_enabled": true + } + } +} +``` + +| Setting | Default | Meaning | +| --- | --- | --- | +| `app.hooks.enabled` | `true` | Master switch. `false` disables all hooks. | +| `app.hooks.project_hooks_enabled` | `false` | Whether the project hook file is honored. | + +**Project hooks are off by default.** A project hook file executes commands +that live inside a checked-out repository, so anyone who can land a commit +could otherwise run code on your machine. Turn it on only for repositories +you trust, and re-check the file after pulling. + +Codex's `[features] hooks = false` has no BitFun equivalent — use +`app.hooks.enabled` instead. + +## Quick start + +Create `/config/hooks.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" + } + ] + } + ] + } +} +``` + +Start a new session and ask the Agent to run a shell command; each command it +runs is appended to `~/bitfun-commands.log`. + +A hook that blocks — here, refusing edits under `migrations/`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [{ "type": "command", "command": "python3 ~/hooks/protect.py" }] + } + ] + } +} +``` + +```python +#!/usr/bin/env python3 +import json, sys + +payload = json.load(sys.stdin) +if "/migrations/" in payload.get("tool_input", {}).get("file_path", ""): + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "Migrations are generated; edit the schema instead.", + } + })) +sys.exit(0) +``` + +## Where BitFun differs from Codex + +Everything not listed here behaves as the Codex documentation describes. + +### Not supported + +| Codex feature | BitFun | +| --- | --- | +| `config.toml` `[hooks]` table | not read — put hooks in `hooks.json` | +| `[features] hooks = false` | use `app.hooks.enabled` | +| Plugin-bundled and managed hooks (`PLUGIN_ROOT`, `managed_dir`) | not supported | +| `prompt` and `agent` handler types | parsed so shared files stay valid, but skipped — only `type: "command"` executes | +| Remote workspaces | hooks are skipped entirely: a local hook process and a remote workspace path do not describe the same filesystem | + +### Fields not populated yet + +| Field or event | Current behavior | +| --- | --- | +| `transcript_path`, `agent_transcript_path` | always `null` | +| `permission_mode` | only `default` or `bypassPermissions` | +| `SessionStart.source` | only `startup`; `resume`, `clear`, `compact` are not dispatched | +| `SessionEnd.reason` | always `other` | +| `SubagentStop.stop_hook_active` | always `false` | +| `SubagentStop` | dispatched when a subagent settles successfully, not on failure, cancellation, or timeout | +| `Stop` | top-level turns only; subagent turns report through `SubagentStop` | + +### Behavior worth knowing + +- **A hook can narrow the permission policy, never widen it.** A `PreToolUse` + `permissionDecision: "allow"` waives the interactive prompt, but a tool call + denied by a permission rule stays denied. +- `suppressOutput` is parsed and currently ignored. +- `continue: false` is honored for `PreToolUse` and `UserPromptSubmit`; for + other events use `decision: "block"`. +- `PostToolUse` fires for error results too, not only successes. +- Limits: 1 MiB per `hooks.json`, 2048 handlers inspected across all layers + (invalid and non-`command` handlers count toward it), and 10,000 bytes of + model-visible text per hook before truncation. + +## Security + +A hook is arbitrary code that runs with your user account's full privileges, +every time its event fires. Treat `hooks.json` like a shell profile: + +- Review any hook you did not write before enabling it. +- Keep project hooks off unless you trust everyone who can commit to the + repository. +- Payload values (prompts, tool arguments, file paths) are model- and + user-supplied text. Parse them as JSON and never interpolate them into a + shell command — that is why the examples above read fields with + `jq`/`json.load`. +- Do not print secrets to stdout for `SessionStart`, `UserPromptSubmit`, or + `SubagentStart`, where plain stdout becomes context the model reads. + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| No hook runs at all | `app.hooks.enabled` is `false`, the file is not at the documented path, or the workspace is remote. | +| Project hooks do not run | `app.hooks.project_hooks_enabled` is `false` (the default). | +| The whole file is ignored | Invalid JSON, or a root key other than `description`/`hooks`. | +| One event is ignored | Misspelled event name — the names are case-sensitive. | +| A handler never runs | Its matcher does not match, or the matcher is not a valid pattern. Matchers are regular expressions anchored to the whole value, so `Bash` matches `Bash` but not `BashOutput`. | +| A `prompt`/`agent` handler never runs | Only `type: "command"` handlers execute. | +| Blocking has no effect | Blocking needs exit code 2 (reason on stderr), or a `decision`/`permissionDecision` field on stdout with exit code 0. | +| Plain `echo` output is not visible to the model | Only `SessionStart`, `UserPromptSubmit`, and `SubagentStart` turn plain stdout into context; elsewhere use `hookSpecificOutput.additionalContext`. | + +Configuration problems, non-zero exits, timeouts, and hook decisions are +written to the BitFun backend log. See +[`src/crates/LOGGING.md`](../../src/crates/LOGGING.md) for how to raise the +log level. + +## Related + +- CLI `/hooks` inspects hooks configured for *other* AI applications (Claude + Code, Codex, OpenCode). That view is read-only and never executes anything; + the hooks described here are BitFun's own and do execute. diff --git a/docs/features/agent-hooks.zh-CN.md b/docs/features/agent-hooks.zh-CN.md new file mode 100644 index 0000000000..033551aad9 --- /dev/null +++ b/docs/features/agent-hooks.zh-CN.md @@ -0,0 +1,199 @@ +# Agent Hooks(生命周期钩子) + +Hooks 让你在 BitFun Agent 生命周期的固定节点运行自己的命令:工具调用前后、 +即将弹出权限确认时、提交提示词时、上下文压缩前后、子 Agent 启动与结束时, +以及会话与回合的开始与结束。一个 Hook 可以观察 Agent 的行为、注入模型可见的 +上下文、改写工具调用参数,或者直接阻止某个动作。 + +## BitFun Hooks 就是 Codex Hooks + +BitFun 实现的是 **Codex Hook 契约**,不是 BitFun 自己的方言: + +- 同样的 `hooks.json` 文档 —— 事件、匹配组、处理器字段; +- 同样的事件名(`PreToolUse`、`PostToolUse`、`PermissionRequest`、 + `UserPromptSubmit`、`PreCompact`、`PostCompact`、`SessionStart`、 + `SessionEnd`、`SubagentStart`、`SubagentStop`、`Stop`); +- 同样的 stdin JSON 载荷,字段名完全一致; +- 同样的退出码语义(`0` 成功、`2` 阻止且 stderr 作为原因、其他为非阻塞错误); +- 同样的 stdout JSON 决策结构(`permissionDecision`、`updatedInput`、 + `additionalContext`、`decision`/`reason` 等)。 + +**Codex 的 Hook 脚本可以直接在 BitFun 中运行,反之亦然 —— 不需要做任何适配。** + +因此本文不重复参考手册。事件语义、各事件的确切载荷字段、决策结构,请直接查阅 +Codex 自己的文档,它把这些写得很完整: + +**→ ** + +本文其余部分只讲 BitFun 特有的内容:文件放在哪、怎么打开、以及目前哪里有差异。 + +## BitFun 从哪里读取 Hooks + +Codex 读 `~/.codex/hooks.json`,BitFun 改为读自己的配置目录。文件内部结构完全相同。 + +| 层级 | 路径 | +| --- | --- | +| 用户 | `<用户配置目录>/config/hooks.json` | +| 项目 | `<工作区>/.bitfun/config/hooks.json` | + +用户配置目录在 Linux 为 `~/.config/bitfun`,macOS 为 +`~/Library/Application Support/bitfun`,Windows 为 `%APPDATA%\bitfun`。 + +两个层级是叠加关系:所有匹配的处理器都会执行,用户层优先,层级之间不存在覆盖或 +屏蔽。修改后无需重启 BitFun。 + +## 开启 Hooks + +**设置 → Agent Hooks**,或直接编辑 `<用户配置目录>/config/app.json` 的 `app` 段: + +```json +{ + "app": { + "hooks": { + "enabled": true, + "project_hooks_enabled": true + } + } +} +``` + +| 配置项 | 默认值 | 含义 | +| --- | --- | --- | +| `app.hooks.enabled` | `true` | 总开关。`false` 会禁用所有 Hooks。 | +| `app.hooks.project_hooks_enabled` | `false` | 是否启用项目 Hook 文件。 | + +**项目级 Hooks 默认关闭。** 项目 Hook 文件执行的是仓库中的命令,任何能提交代码的 +人都可能借此在你的机器上执行代码。请只对你信任的仓库开启,并在拉取代码后重新 +检查该文件。 + +Codex 的 `[features] hooks = false` 在 BitFun 没有对应项,请使用 +`app.hooks.enabled`。 + +## 快速开始 + +创建 `<用户配置目录>/config/hooks.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" + } + ] + } + ] + } +} +``` + +新建一个会话,让 Agent 执行一条 shell 命令,它执行的每条命令都会追加到 +`~/bitfun-commands.log`。 + +一个会阻止操作的 Hook —— 这里拒绝修改 `migrations/` 下的文件: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [{ "type": "command", "command": "python3 ~/hooks/protect.py" }] + } + ] + } +} +``` + +```python +#!/usr/bin/env python3 +import json, sys + +payload = json.load(sys.stdin) +if "/migrations/" in payload.get("tool_input", {}).get("file_path", ""): + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "迁移文件由生成器产出,请改动 schema。", + } + })) +sys.exit(0) +``` + +## BitFun 与 Codex 的差异 + +未在此列出的部分,行为与 Codex 文档所述一致。 + +### 不支持 + +| Codex 能力 | BitFun | +| --- | --- | +| `config.toml` 的 `[hooks]` 表 | 不读取 —— 请把 Hook 写在 `hooks.json` | +| `[features] hooks = false` | 使用 `app.hooks.enabled` | +| 插件内置与托管 Hooks(`PLUGIN_ROOT`、`managed_dir`) | 不支持 | +| `prompt` 与 `agent` 处理器类型 | 会被解析(以便共享配置文件保持有效)但跳过 —— 只有 `type: "command"` 会执行 | +| 远程工作区 | 完全跳过 Hooks:本地 Hook 进程与远程工作区路径描述的不是同一个文件系统 | + +### 尚未填充的字段 + +| 字段或事件 | 当前行为 | +| --- | --- | +| `transcript_path`、`agent_transcript_path` | 恒为 `null` | +| `permission_mode` | 只会是 `default` 或 `bypassPermissions` | +| `SessionStart.source` | 只有 `startup`;`resume`、`clear`、`compact` 尚未派发 | +| `SessionEnd.reason` | 恒为 `other` | +| `SubagentStop.stop_hook_active` | 恒为 `false` | +| `SubagentStop` | 仅在子 Agent 成功结束时派发;失败、取消或超时不会派发 | +| `Stop` | 仅顶层回合触发;子 Agent 回合通过 `SubagentStop` 上报 | + +### 值得了解的行为 + +- **Hook 只能收紧权限策略,永远无法放宽。** `PreToolUse` 的 + `permissionDecision: "allow"` 只免去交互式确认;被权限规则拒绝的工具调用依然 + 会被拒绝。 +- `suppressOutput` 会被解析,但当前被忽略。 +- `continue: false` 对 `PreToolUse` 和 `UserPromptSubmit` 生效;其他事件请使用 + `decision: "block"`。 +- `PostToolUse` 在工具返回错误结果时同样会触发,不只是成功时。 +- 限制:单个 `hooks.json` 最大 1 MiB;所有层级最多检查 2048 个处理器(无效处理器 + 和非 `command` 处理器同样计入);单个 Hook 的模型可见文本上限 10,000 字节, + 超出会截断。 + +## 安全 + +Hook 是以你的用户权限运行的任意代码,且每次对应事件触发都会运行。请像对待 shell +配置文件那样对待 `hooks.json`: + +- 启用任何非你本人编写的 Hook 之前先审阅它。 +- 除非你信任所有能向仓库提交代码的人,否则保持项目级 Hooks 关闭。 +- 载荷中的值(提示词、工具参数、文件路径)是模型和用户提供的文本。请按 JSON 解析, + 不要拼接进 shell 命令 —— 上面的示例正是为此用 `jq` / `json.load` 读取字段。 +- 不要在 `SessionStart`、`UserPromptSubmit`、`SubagentStart` 中把密钥打印到 + stdout,这些事件的普通 stdout 会成为模型可见的上下文。 + +## 排查 + +| 现象 | 原因 | +| --- | --- | +| 完全没有 Hook 运行 | `app.hooks.enabled` 为 `false`、文件不在文档所述路径,或工作区是远程工作区。 | +| 项目 Hooks 不运行 | `app.hooks.project_hooks_enabled` 为 `false`(默认值)。 | +| 整个文件被忽略 | JSON 无效,或存在 `description`/`hooks` 之外的根级字段。 | +| 某个事件被忽略 | 事件名拼写错误 —— 事件名区分大小写。 | +| 某个处理器从不运行 | matcher 不匹配,或 matcher 不是合法模式。matcher 是对整个值做锚定匹配的正则表达式,因此 `Bash` 匹配 `Bash` 但不匹配 `BashOutput`。 | +| `prompt`/`agent` 处理器从不运行 | 只有 `type: "command"` 处理器会执行。 | +| 阻止没有生效 | 阻止需要退出码 2(原因写入 stderr),或退出码 0 时在 stdout 输出 `decision`/`permissionDecision` 字段。 | +| 模型看不到普通 `echo` 输出 | 只有 `SessionStart`、`UserPromptSubmit`、`SubagentStart` 会把普通 stdout 转为上下文;其他事件请使用 `hookSpecificOutput.additionalContext`。 | + +配置问题、非零退出、超时以及 Hook 决策都会写入 BitFun 后端日志。提升日志级别的 +方法见 [`src/crates/LOGGING.md`](../../src/crates/LOGGING.md)。 + +## 相关 + +- CLI 的 `/hooks` 用于查看*其他* AI 应用(Claude Code、Codex、OpenCode)配置的 + Hooks。该视图只读,不会执行任何内容;本文描述的是 BitFun 自身的 Hooks,它们会 + 真正执行。 diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index b0c235a07f..9cac4b5f31 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -49,6 +49,7 @@ use crate::agentic::tools::{ }; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; +use crate::native_hooks::{self, NativeHookSessionFacts}; use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; @@ -1812,9 +1813,50 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet remote_ssh_host: session.config.remote_ssh_host.clone(), }) .await; + Self::dispatch_session_start_hooks(&session, "startup").await; Ok(session) } + /// Session-scope hook facts. Session-lifecycle events carry no turn id. + fn session_hook_facts<'a>( + session: &'a Session, + workspace_root: Option<&'a Path>, + is_remote_workspace: bool, + ) -> NativeHookSessionFacts<'a> { + NativeHookSessionFacts { + session_id: &session.session_id, + turn_id: None, + workspace_root, + is_remote_workspace, + model: session.config.model_id.as_deref().unwrap_or_default(), + bypass_permissions: false, + } + } + + /// Whether hook dispatch for this session must be skipped as remote. + /// + /// The workspace binding is the authority — a persisted `SessionConfig` + /// can legitimately lose its remote connection id. A session that binds + /// no workspace at all is treated as remote so dispatch fails closed. + async fn session_hooks_are_remote(session: &Session) -> bool { + match Self::build_workspace_binding(&session.config).await { + Some(binding) => binding.is_remote(), + None => session.config.workspace_path.is_some(), + } + } + + /// Run SessionStart hooks. `source` follows the Codex vocabulary: + /// `startup` | `resume` | `clear` | `compact`. + async fn dispatch_session_start_hooks(session: &Session, source: &str) { + let workspace_root = session.config.workspace_path.as_ref().map(Path::new); + let is_remote = Self::session_hooks_are_remote(session).await; + native_hooks::dispatch_session_start( + Self::session_hook_facts(session, workspace_root, is_remote), + source, + ) + .await; + } + /// Create a hidden internal subagent session that is persisted but excluded /// from normal user-facing session lists. pub async fn create_hidden_subagent_session_with_workspace( @@ -3639,6 +3681,40 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } + // UserPromptSubmit hooks run before any turn state is created, so a + // blocking hook rejects the prompt without leaving a partial turn. + // Their context (plus buffered SessionStart context) is prepended to + // the turn as internal reminders. + let hook_prompt_decision = native_hooks::dispatch_user_prompt_submit( + NativeHookSessionFacts { + turn_id: turn_id.as_deref(), + ..Self::session_hook_facts( + &session, + session.config.workspace_path.as_deref().map(Path::new), + Self::session_hooks_are_remote(&session).await, + ) + }, + &user_input, + ) + .await; + if let Some(reason) = hook_prompt_decision.block_reason { + info!( + "UserPromptSubmit hook blocked the prompt: session_id={}, reason={}", + session_id, reason + ); + return Err(BitFunError::Validation(format!( + "A UserPromptSubmit hook blocked this prompt: {reason}" + ))); + } + let mut hook_context_sections = native_hooks::take_pending_session_context(&session_id); + hook_context_sections.extend(hook_prompt_decision.additional_context); + for section in hook_context_sections { + additional_prepended_messages.push(Message::internal_reminder( + InternalReminderKind::HookContext, + format!("\n{section}\n"), + )); + } + // Ensure session history is loaded into memory // Critical fix: prevent unloaded history after app restart let context_messages = self @@ -4677,6 +4753,31 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: &Path, session_id: &str, ) -> BitFunResult<()> { + // SessionEnd hooks observe the session before its state is gone. + // Their timeout is capped tightly so deletion cannot hang. + let session_hook_facts = match self.session_manager.get_session(session_id) { + Some(session) => Some(( + Self::session_hooks_are_remote(&session).await, + session.config.model_id.clone().unwrap_or_default(), + )), + None => None, + }; + if let Some((is_remote_workspace, model)) = session_hook_facts { + native_hooks::dispatch_session_end( + NativeHookSessionFacts { + session_id, + turn_id: None, + workspace_root: Some(workspace_path), + is_remote_workspace, + model: &model, + bypass_permissions: false, + }, + "other", + ) + .await; + } else { + native_hooks::clear_session_hook_state(session_id); + } self.session_manager .delete_session(workspace_path, session_id) .await?; @@ -5861,6 +5962,35 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "Subagent task has been cancelled".to_string(), )); } + // SubagentStart hooks observe the subagent before its first round; + // plain stdout becomes model-visible context for the subagent. + // Owned copies survive `subagent_workspace` moving into the context. + let mut initial_messages = initial_messages; + let subagent_hook_workspace_root = subagent_workspace + .as_ref() + .map(|workspace| workspace.root_path().to_path_buf()); + let subagent_hook_is_remote = subagent_workspace + .as_ref() + .is_some_and(|workspace| workspace.is_remote()); + let subagent_hook_model = session.config.model_id.clone().unwrap_or_default(); + let subagent_hook_facts = NativeHookSessionFacts { + session_id: &session_id, + turn_id: Some(&dialog_turn_id), + workspace_root: subagent_hook_workspace_root.as_deref(), + is_remote_workspace: subagent_hook_is_remote, + model: &subagent_hook_model, + bypass_permissions: false, + }; + for section in + native_hooks::dispatch_subagent_start(subagent_hook_facts, &session_id, &agent_type) + .await + { + initial_messages.push(Message::internal_reminder( + InternalReminderKind::HookContext, + format!("\n{section}\n"), + )); + } + let subagent_services = Self::build_workspace_services(&subagent_workspace).await; let execution_context = ExecutionContext { session_id: session_id.clone(), @@ -6385,6 +6515,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await; + // SubagentStop hooks observe the settled subagent turn. A blocking + // decision is recorded for the operator; it does not restart the + // subagent, because its result has already been persisted. + if let Some(reason) = native_hooks::dispatch_subagent_stop( + subagent_hook_facts, + &session_id, + &agent_type, + Some(response_text.as_str()).filter(|text| !text.trim().is_empty()), + ) + .await + { + warn!( + "SubagentStop hook reported a blocking decision after the subagent settled: agent_type={}, session_id={}, reason={}", + agent_type, session_id, reason + ); + } + // Clean up subagent session resources after successful execution debug!( "Subagent successful execution produced final text: agent_type={}, session_id={}, dialog_turn_id={}, parent_session_id={}, parent_dialog_turn_id={}, parent_tool_call_id={}, text_len={}, duration_ms={}", diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index d5f9053fc7..da8de36512 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -110,6 +110,11 @@ pub enum InternalReminderKind { InterruptedContinue, ThinkingOnlyRescue, FinalizeCacheAnchor, + /// A Stop hook blocked the end of a turn and asked the agent to continue. + StopHookBlock, + /// Model-visible context contributed by a SessionStart or + /// UserPromptSubmit hook. + HookContext, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -140,6 +145,11 @@ impl InternalReminderKind { | Self::InterruptedContinue | Self::ThinkingOnlyRescue | Self::FinalizeCacheAnchor + // Mid-turn scaffolding: the Stop hook's feedback matters only + // while the reopened turn is still running. HookContext is + // deliberately absent — it carries real context a hook asked + // the model to keep. + | Self::StopHookBlock ) } diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 8c78c4d7aa..27da5c379e 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -37,6 +37,7 @@ use crate::agentic::tools::{ }; use crate::agentic::WorkspaceBinding; use crate::infrastructure::ai::get_global_ai_client_factory; +use crate::native_hooks::{self, NativeHookSessionFacts}; use crate::service::config::get_global_config_service; use crate::service::config::types::{ automatic_max_output_tokens, model_runtime_binding_fingerprint, ModelCapability, ModelCategory, @@ -1954,6 +1955,34 @@ impl ExecutionEngine { }) } + /// Plain assistant text of a message, when it has any. + fn assistant_message_text(message: &Message) -> Option<&str> { + match &message.content { + MessageContent::Text(text) => Some(text.as_str()), + MessageContent::Multimodal { text, .. } => Some(text.as_str()), + _ => None, + } + .map(str::trim) + .filter(|text| !text.is_empty()) + } + + /// Native hook session facts for a compaction or turn-lifecycle dispatch. + fn native_hook_facts<'a>( + session_id: &'a str, + dialog_turn_id: &'a str, + workspace: Option<&'a WorkspaceBinding>, + model: &'a str, + ) -> NativeHookSessionFacts<'a> { + NativeHookSessionFacts { + session_id, + turn_id: Some(dialog_turn_id), + workspace_root: workspace.map(|workspace| workspace.root_path()), + is_remote_workspace: workspace.is_some_and(|workspace| workspace.is_remote()), + model, + bypass_permissions: false, + } + } + /// Compress context, will emit compression events (Started, Completed, and Failed) #[allow(clippy::too_many_arguments)] async fn compress_messages( @@ -1989,6 +2018,14 @@ impl ExecutionEngine { // Generate compression ID let compression_id = format!("compression_{}", uuid::Uuid::new_v4()); + // Captured before `ai_client` is consumed by summary generation. + let ai_client_model = ai_client.config.model.clone(); + + native_hooks::dispatch_pre_compact( + Self::native_hook_facts(session_id, dialog_turn_id, workspace, &ai_client_model), + "auto", + ) + .await; // Emit compression started event self.emit_event( @@ -2188,6 +2225,17 @@ impl ExecutionEngine { ) .await; + native_hooks::dispatch_post_compact( + Self::native_hook_facts( + session_id, + dialog_turn_id, + workspace, + &ai_client_model, + ), + "auto", + ) + .await; + Ok(Some((compressed_tokens, new_messages))) } Err(e) => { @@ -2228,6 +2276,16 @@ impl ExecutionEngine { let scaffold = self .resolve_compression_runtime_scaffold(&session, &context) .await?; + native_hooks::dispatch_pre_compact( + Self::native_hook_facts( + &session_id, + &dialog_turn_id, + context.workspace.as_ref(), + &scaffold.ai_client.config.model, + ), + trigger, + ) + .await; let context_window = (scaffold.ai_client.config.context_window as usize) .min(session.config.max_context_tokens); let prepended_reminders = scaffold.prepended_prompt_reminders.ordered_reminders(); @@ -2490,6 +2548,17 @@ impl ExecutionEngine { ) .await; + native_hooks::dispatch_post_compact( + Self::native_hook_facts( + &session_id, + &dialog_turn_id, + context.workspace.as_ref(), + &scaffold.ai_client.config.model, + ), + trigger, + ) + .await; + Ok(ContextCompactionOutcome { compression_id, compression_count, @@ -2893,6 +2962,9 @@ impl ExecutionEngine { // is not a stop condition. let mut thinking_only_rescue_attempts: usize = 0; let mut partial_continuation_attempts: usize = 0; + // Bounds how often Stop hooks may reopen a finished turn. + let mut stop_hook_continuations: usize = 0; + const MAX_STOP_HOOK_CONTINUATIONS: usize = 3; // Add detailed logging showing the execution context messages. debug!( @@ -3659,7 +3731,61 @@ impl ExecutionEngine { "Model round {} ended with final answer, reason: {:?}", round_index, round_result.finish_reason ); - break; + // Stop hooks may block the natural end of the turn and + // ask the agent to keep working. `stop_hook_active` + // tells the hook it is already running inside such a + // continuation so it can avoid an endless loop, and the + // engine caps continuations regardless. + // Subagent turns run through this same loop; their + // completion is reported by SubagentStop instead, so + // Stop stays a top-level-turn event as in Codex. + let stop_block_reason = if context.subagent_parent_info.is_none() + && stop_hook_continuations < MAX_STOP_HOOK_CONTINUATIONS + { + native_hooks::dispatch_stop( + Self::native_hook_facts( + &context.session_id, + &context.dialog_turn_id, + context.workspace.as_ref(), + &ai_client.config.model, + ), + stop_hook_continuations > 0, + Self::assistant_message_text(&round_result.assistant_message), + ) + .await + } else { + None + }; + if let Some(reason) = stop_block_reason { + stop_hook_continuations += 1; + let reminder = format!( + "A Stop hook blocked the end of this turn: {reason}\nAddress this before finishing, then produce your final answer." + ); + let user_msg = Message::internal_reminder( + InternalReminderKind::StopHookBlock, + reminder, + ) + .with_turn_id(context.dialog_turn_id.clone()); + messages.push(user_msg.clone()); + if let Err(e) = self + .session_manager + .add_message(&context.session_id, user_msg) + .await + { + warn!("Failed to persist Stop hook reminder: {}", e); + } + info!( + "Stop hook blocked turn completion; continuing turn #{}/{}: turn={}, round={}", + stop_hook_continuations, + MAX_STOP_HOOK_CONTINUATIONS, + context.dialog_turn_id, + round_index + ); + // Continue into the next round so the agent can act + // on the hook feedback. + } else { + break; + } } } else if round_result.had_thinking_content { thinking_only_rescue_attempts += 1; diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index f2bef41a47..6c323256fb 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -91,6 +91,18 @@ impl ToolStateManager { self.tasks.get(tool_id).map(|t| t.clone()) } + /// Replace a task's effective tool arguments before execution. + /// Used by PreToolUse hook `updatedInput` rewrites; later readers + /// (validation, permission planning, execution) observe the new value. + pub fn update_task_arguments(&self, tool_id: &str, arguments: serde_json::Value) -> bool { + if let Some(mut task) = self.tasks.get_mut(tool_id) { + task.invocation.effective_arguments = arguments; + true + } else { + false + } + } + /// Get all tasks of a session pub fn get_session_tasks(&self, session_id: &str) -> Vec { self.tasks diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index b5d65fe69a..035014b0d0 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -13,6 +13,7 @@ use crate::agentic::tools::registry::ToolRegistry; use crate::agentic::tools::tool_context_runtime; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::tool_result_storage; +use crate::native_hooks::{self, NativeHookSessionFacts}; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_runtime::permission::{ @@ -37,7 +38,7 @@ use bitfun_runtime_ports::{ }; use futures::future::join_all; use log::{debug, error, info, warn}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Instant, SystemTime}; use tokio::sync::{Mutex as TokioMutex, RwLock as TokioRwLock}; @@ -623,6 +624,27 @@ fn permission_intent_effect( const SUBAGENT_LAUNCH_TOOL_NAME: &str = "Task"; +/// Native hook session facts derived from one tool task. +fn native_hook_session_facts<'a>( + context: &'a ToolExecutionContext, + options: &ToolExecutionOptions, +) -> NativeHookSessionFacts<'a> { + NativeHookSessionFacts { + session_id: &context.session_id, + turn_id: Some(&context.dialog_turn_id), + workspace_root: context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), + is_remote_workspace: context + .workspace + .as_ref() + .is_some_and(|workspace| workspace.is_remote()), + model: &context.primary_model_facts.model_id, + bypass_permissions: options.auto_approve_ask, + } +} + /// Tool pipeline #[derive(Clone)] pub struct ToolPipeline { @@ -632,6 +654,9 @@ pub struct ToolPipeline { computer_use_host: Option, permission_request_manager: Option>, permission_plans: Arc>>, + /// Tool task ids a PreToolUse hook approved. The approval waives the + /// interactive permission prompt only; policy denials still apply. + hook_preapprovals: Arc>>, } impl ToolPipeline { @@ -647,6 +672,7 @@ impl ToolPipeline { computer_use_host, permission_request_manager: None, permission_plans: Arc::new(TokioMutex::new(HashMap::new())), + hook_preapprovals: Arc::new(TokioMutex::new(HashSet::new())), } } @@ -716,6 +742,40 @@ impl ToolPipeline { return Ok(PermissionPlanDraft::Allowed); } + // A PreToolUse hook already approved this call. The approval reaches + // here — after policy evaluation — precisely so that it waives only + // the interactive prompt: a policy Deny above has already returned. + if self.hook_preapprovals.lock().await.contains(&tool_call_id) { + return Ok(PermissionPlanDraft::Allowed); + } + + // The tool call would prompt the user: give PermissionRequest hooks + // a chance to decide first. An explicit hook decision replaces the + // interactive prompt for this invocation. + if let Some(hook_decision) = native_hooks::dispatch_permission_request( + native_hook_session_facts(&task.context, &task.options), + &tool_name, + &task.invocation.effective_arguments, + ) + .await + { + if hook_decision.allow { + info!( + "PermissionRequest hook allowed tool call without prompting: tool_name={}", + tool_name + ); + return Ok(PermissionPlanDraft::Allowed); + } + let reason = hook_decision.message.unwrap_or_else(|| { + format!("A PermissionRequest hook denied the '{tool_name}' tool call.") + }); + info!( + "PermissionRequest hook denied tool call: tool_name={}", + tool_name + ); + return Ok(PermissionPlanDraft::Rejected { reason }); + } + if manager.is_none() { return Err(BitFunError::service( "Permission request manager is unavailable for a file tool request".to_string(), @@ -800,11 +860,120 @@ impl ToolPipeline { Ok(receivers) } + /// Run PreToolUse hooks for every valid task and record their decisions + /// as pre-seeded permission plans. `updatedInput` rewrites the stored + /// task arguments before validation and permission planning observe them. + async fn apply_pre_tool_use_hooks(&self, task_ids: &[String]) { + for task_id in task_ids { + let Some(task) = self.state_manager.get_task(task_id) else { + continue; + }; + if task.invocation_resolution_error.is_some() + || task.tool_call.tool_name.is_empty() + || task.tool_call.is_error + { + continue; + } + let tool_name = task.invocation.effective_tool_name.clone(); + let decision = native_hooks::dispatch_pre_tool_use( + native_hook_session_facts(&task.context, &task.options), + &tool_name, + &task.tool_call.tool_id, + &task.invocation.effective_arguments, + ) + .await; + if let Some(updated_input) = decision.updated_input { + if self + .state_manager + .update_task_arguments(task_id, updated_input) + { + info!( + "PreToolUse hook rewrote tool arguments: tool_name={}, tool_id={}", + tool_name, task_id + ); + } + } + if let Some(reason) = decision.deny_reason { + // A hook denial is strictly more restrictive than the + // permission policy, so it can short-circuit planning. + info!( + "PreToolUse hook denied tool call: tool_name={}, tool_id={}", + tool_name, task_id + ); + self.permission_plans.lock().await.insert( + task_id.clone(), + PermissionExecutionPlan::Rejected { reason }, + ); + } else if decision.allow { + // A hook approval only waives the interactive prompt. It is + // recorded for the planner rather than short-circuiting it, + // so a policy Deny still rejects the call. + info!( + "PreToolUse hook approved tool call without prompting: tool_name={}, tool_id={}", + tool_name, task_id + ); + self.hook_preapprovals.lock().await.insert(task_id.clone()); + } + } + } + + /// Run PostToolUse hooks for a completed tool call and fold blocking + /// feedback and additional context into the model-visible result text. + async fn apply_post_tool_use_hooks( + &self, + task: &ToolTask, + tool_name: &str, + tool_id: &str, + tool_result: &mut ModelToolResult, + ) { + let tool_response = serde_json::json!({ + "result": match &tool_result.result_for_assistant { + Some(text) => serde_json::Value::String(text.clone()), + None => tool_result.result.clone(), + }, + "is_error": tool_result.is_error, + }); + let decision = native_hooks::dispatch_post_tool_use( + native_hook_session_facts(&task.context, &task.options), + tool_name, + tool_id, + &task.invocation.effective_arguments, + &tool_response, + ) + .await; + let mut hook_sections = Vec::new(); + if let Some(reason) = decision.block_reason { + info!( + "PostToolUse hook returned blocking feedback: tool_name={}, tool_id={}", + tool_name, tool_id + ); + hook_sections.push(format!("PostToolUse hook feedback (blocking): {reason}")); + } + for context in decision.additional_context { + hook_sections.push(format!("PostToolUse hook context: {context}")); + } + if hook_sections.is_empty() { + return; + } + let original = tool_result.result_for_assistant.take().unwrap_or_default(); + let appended = hook_sections.join("\n"); + tool_result.result_for_assistant = Some(if original.is_empty() { + appended + } else { + format!("{original}\n\n{appended}") + }); + } + async fn prepare_permission_plans(&self, task_ids: &[String]) -> BitFunResult<()> { let mut drafts = Vec::with_capacity(task_ids.len()); let mut ordered_requests = Vec::new(); for task_id in task_ids { + // A PreToolUse hook decision already produced a plan for this + // task; keep it instead of drafting (and possibly prompting). + if self.permission_plans.lock().await.contains_key(task_id) { + continue; + } let Some(task) = self.state_manager.get_task(task_id) else { continue; }; @@ -1035,6 +1204,14 @@ impl ToolPipeline { } async fn cleanup_permission_plans(&self, task_ids: &[String], reason: String) { + { + // Hook approvals are scoped to the batch that produced them; a + // later call must be evaluated on its own merits. + let mut preapprovals = self.hook_preapprovals.lock().await; + for task_id in task_ids { + preapprovals.remove(task_id); + } + } for task_id in task_ids { let Some(plan) = self.permission_plans.lock().await.remove(task_id) else { continue; @@ -1278,6 +1455,11 @@ impl ToolPipeline { task_ids.push(tool_id); } + // PreToolUse hooks run before permission planning so a hook decision + // (deny / pre-approve / rewritten input) is visible to the planner + // and no permission prompt is raised for calls a hook already decided. + self.apply_pre_tool_use_hooks(&task_ids).await; + if let Err(error) = self.prepare_permission_plans(&task_ids).await { self.cleanup_permission_plans(&task_ids, "Permission planning failed".to_string()) .await; @@ -1756,6 +1938,9 @@ impl ToolPipeline { }); } + self.apply_post_tool_use_hooks(&task, &tool_name, &tool_id, &mut tool_result) + .await; + self.state_manager .update_state( &tool_id, @@ -2971,6 +3156,99 @@ mod tests { assert_eq!(results[0].result.result["category"], "permission_denied"); } + /// A PreToolUse hook approval waives the interactive permission prompt. + /// It must never widen the policy: a rule that denies the call still + /// rejects it, and the tool never runs. + #[tokio::test] + async fn hook_approval_does_not_override_a_permission_deny_rule() { + let pipeline = test_tool_pipeline(); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/private/key.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + // Stand in for a hook that returned permissionDecision: "allow". + pipeline + .hook_preapprovals + .lock() + .await + .insert("hook-approved".to_string()); + + let mut deny_options = ToolExecutionOptions::default(); + deny_options.permission_rules = vec![PermissionRule::new( + "edit", + "src/private/*", + PermissionEffect::Deny, + )]; + let results = pipeline + .execute_tools( + vec![test_tool_call("hook-approved", "Write")], + permission_test_context(), + deny_options, + ) + .await + .expect("denied tool should return a structured rejection"); + + assert!(matches!( + pipeline + .state_manager + .get_task("hook-approved") + .map(|task| task.state), + Some(ToolExecutionState::Rejected { .. }) + )); + assert_eq!(results[0].result.result["category"], "permission_denied"); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "a denied tool must not execute even when a hook approved it" + ); + } + + /// The same approval does waive an interactive prompt when the policy + /// only asks, so the call proceeds without a permission request. + #[tokio::test] + async fn hook_approval_waives_the_permission_prompt() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + pipeline + .hook_preapprovals + .lock() + .await + .insert("hook-approved".to_string()); + + // No rule matches, so the policy would ask; nobody answers the prompt + // in this test, so completing at all proves the prompt was waived. + let results = pipeline + .execute_tools( + vec![test_tool_call("hook-approved", "Write")], + permission_test_context(), + ToolExecutionOptions::default(), + ) + .await + .expect("hook-approved tool should execute"); + + assert!(!results[0].result.is_error); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn v2_rejecting_one_parallel_tool_does_not_reject_sibling() { let store = Arc::new(MemoryPermissionStore::default()); diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 8149e85159..2d6b6f55ee 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -88,8 +88,8 @@ impl PathManager { /// Get user config root directory /// - /// - Windows: %APPDATA%\BitFun\ - /// - macOS: ~/Library/Application Support/BitFun/ + /// - Windows: %APPDATA%\bitfun\ + /// - macOS: ~/Library/Application Support/bitfun/ /// - Linux: ~/.config/bitfun/ fn get_user_config_root() -> BitFunResult { if let Some(path) = @@ -225,6 +225,11 @@ impl PathManager { self.user_config_dir().join("app.json") } + /// Get user agent hooks file: ~/.config/bitfun/config/hooks.json + pub fn user_hooks_file(&self) -> PathBuf { + self.user_config_dir().join("hooks.json") + } + /// Get user agent directory: ~/.config/bitfun/agents/ pub fn user_agents_dir(&self) -> PathBuf { self.user_root.join("agents") @@ -427,6 +432,12 @@ impl PathManager { .join("agent_subagents.json") } + /// Get project agent hooks file: {project}/.bitfun/config/hooks.json + pub fn project_hooks_file(&self, workspace_path: &Path) -> PathBuf { + self.project_internal_config_dir(workspace_path) + .join("hooks.json") + } + /// Get project agent directory: {project}/.bitfun/agents/ pub fn project_agents_dir(&self, workspace_path: &Path) -> PathBuf { self.project_root(workspace_path).join("agents") diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 67c603011d..0ec1e22d49 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -26,6 +26,10 @@ pub mod infrastructure; // AI clients, storage, logging, events #[cfg(feature = "product-domains")] pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) #[cfg(feature = "product-full")] +pub mod native_hooks; +#[cfg(all(test, feature = "product-full"))] +mod native_hooks_tests; +#[cfg(feature = "product-full")] pub mod plugin_runtime; #[cfg(any(feature = "plugin-source", feature = "product-domains"))] pub mod plugin_source; diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs new file mode 100644 index 0000000000..ee8c13c3f1 --- /dev/null +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -0,0 +1,566 @@ +//! Product wiring for native BitFun agent hooks. +//! +//! This module connects the portable hook engine +//! (`bitfun_agent_runtime::native_hooks`) to BitFun configuration and the +//! agent runtime dispatch sites: +//! +//! - Settings discovery: user scope `~/.config/bitfun/config/hooks.json` +//! plus project scope `{project}/.bitfun/config/hooks.json`, both using the +//! Codex-compatible `hooks.json` document schema. +//! - Gating: `hooks.enabled` and `hooks.project_hooks_enabled` in the app +//! settings document. Project hooks are disabled by default because they +//! execute commands declared inside the checked-out repository. +//! - Dispatch: typed helpers per lifecycle event, called from the +//! conversation coordinator, execution engine, and tool pipeline. +//! +//! Hooks always execute on the local host. Remote workspaces skip hook +//! dispatch because the payload `cwd` and the hook process would disagree +//! about the filesystem they describe. + +use crate::infrastructure::try_get_path_manager_arc; +use crate::service::config::get_global_config_service; +pub use crate::service::config::types::AgentHooksConfig; +use bitfun_agent_runtime::native_hooks::{ + AgentHookEngine, AgentHookEvent, AgentHookEventPayload, AgentHookOutcome, AgentHookPayload, + AgentHookPayloadCommon, AgentHookPermissionMode, AgentHookPermissionOutcome, AgentHookScope, + AgentHookSettings, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, +}; +use dashmap::DashMap; +use log::{debug, info, warn}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +const MAX_CACHED_WORKSPACE_ENGINES: usize = 32; +const MAX_PENDING_CONTEXT_SESSIONS: usize = 1024; + +/// Everything a dispatch site knows about the running session. +#[derive(Debug, Clone, Copy)] +pub struct NativeHookSessionFacts<'a> { + pub session_id: &'a str, + /// Present for turn-scoped events. + pub turn_id: Option<&'a str>, + pub workspace_root: Option<&'a Path>, + pub is_remote_workspace: bool, + pub model: &'a str, + /// Maps to payload `permission_mode`: `bypassPermissions` when the turn + /// auto-approves permission asks, `default` otherwise. + pub bypass_permissions: bool, +} + +#[derive(Debug, Default)] +pub struct UserPromptSubmitHookDecision { + /// The prompt must not start; the reason is shown to the caller. + pub block_reason: Option, + /// Model-visible context to prepend to the turn. + pub additional_context: Vec, +} + +#[derive(Debug, Default)] +pub struct PreToolUseHookDecision { + /// The tool call must not run; the reason is fed back to the model. + pub deny_reason: Option, + /// The tool call bypasses the permission prompt for this invocation. + pub allow: bool, + /// Replacement tool arguments (`hookSpecificOutput.updatedInput`). + pub updated_input: Option, +} + +#[derive(Debug)] +pub struct PermissionRequestHookDecision { + pub allow: bool, + pub message: Option, +} + +#[derive(Debug, Default)] +pub struct PostToolUseHookDecision { + /// Feedback the model must see (`decision: "block"` reason). + pub block_reason: Option, + /// Extra model-visible context (`hookSpecificOutput.additionalContext`). + pub additional_context: Vec, +} + +/// SessionStart hooks run when a session is created or restored. +/// Plain stdout context is buffered and injected into the next turn. +pub async fn dispatch_session_start(facts: NativeHookSessionFacts<'_>, source: &str) { + let Some(dispatch) = prepare(facts, AgentHookEvent::SessionStart).await else { + return; + }; + let outcome = dispatch + .run(AgentHookEventPayload::SessionStart { + source: source.to_string(), + }) + .await; + let mut context = outcome.additional_context.clone(); + context.retain(|entry| !entry.trim().is_empty()); + if !context.is_empty() { + let pending = pending_session_context(); + if pending.len() < MAX_PENDING_CONTEXT_SESSIONS { + pending + .entry(facts.session_id.to_string()) + .or_default() + .extend(context); + } + } +} + +/// Drain SessionStart context buffered for this session. +pub fn take_pending_session_context(session_id: &str) -> Vec { + pending_session_context() + .remove(session_id) + .map(|(_, context)| context) + .unwrap_or_default() +} + +/// UserPromptSubmit hooks run before the user prompt becomes a turn. A +/// blocking decision rejects the prompt; plain stdout and +/// `additionalContext` become model-visible context for the turn. +pub async fn dispatch_user_prompt_submit( + facts: NativeHookSessionFacts<'_>, + prompt: &str, +) -> UserPromptSubmitHookDecision { + let mut decision = UserPromptSubmitHookDecision::default(); + let Some(dispatch) = prepare(facts, AgentHookEvent::UserPromptSubmit).await else { + return decision; + }; + let outcome = dispatch + .run(AgentHookEventPayload::UserPromptSubmit { + prompt: prompt.to_string(), + }) + .await; + decision.block_reason = outcome.block_reason.clone().or(outcome.stop_reason.clone()); + decision.additional_context = outcome.additional_context.clone(); + decision +} + +/// PreToolUse hooks run after tool-input validation and before permission +/// evaluation. They may deny the call, pre-approve it, or rewrite its input. +pub async fn dispatch_pre_tool_use( + facts: NativeHookSessionFacts<'_>, + tool_name: &str, + tool_use_id: &str, + tool_input: &Value, +) -> PreToolUseHookDecision { + let mut decision = PreToolUseHookDecision::default(); + let Some(dispatch) = prepare(facts, AgentHookEvent::PreToolUse).await else { + return decision; + }; + let outcome = dispatch + .run(AgentHookEventPayload::PreToolUse { + tool_name: tool_name.to_string(), + tool_use_id: tool_use_id.to_string(), + tool_input: tool_input.clone(), + }) + .await; + decision.updated_input = outcome.updated_input.clone(); + match &outcome.permission { + Some(AgentHookPermissionOutcome::Deny { reason }) => { + decision.deny_reason = Some(reason.clone().unwrap_or_else(|| { + format!("A PreToolUse hook denied the '{tool_name}' tool call.") + })); + } + Some(AgentHookPermissionOutcome::Allow { .. }) => { + decision.allow = true; + } + None => {} + } + if decision.deny_reason.is_none() { + if let Some(reason) = outcome.block_reason.clone() { + decision.deny_reason = Some(reason); + } else if let Some(reason) = outcome.stop_reason.clone() { + // `continue: false` asks to stop the turn; the closest safe + // enforcement at this dispatch site is denying the tool call. + warn!( + "PreToolUse hook requested a full turn stop; denying the tool call instead: tool={}", + tool_name + ); + decision.deny_reason = Some(reason); + } + } + if decision.deny_reason.is_some() { + decision.allow = false; + decision.updated_input = None; + } + decision +} + +/// PermissionRequest hooks run when a tool call would prompt the user. +/// Returns a decision only when a hook explicitly allowed or denied. +pub async fn dispatch_permission_request( + facts: NativeHookSessionFacts<'_>, + tool_name: &str, + tool_input: &Value, +) -> Option { + let dispatch = prepare(facts, AgentHookEvent::PermissionRequest).await?; + let outcome = dispatch + .run(AgentHookEventPayload::PermissionRequest { + tool_name: tool_name.to_string(), + tool_input: tool_input.clone(), + }) + .await; + if let Some(reason) = outcome.block_reason.clone() { + return Some(PermissionRequestHookDecision { + allow: false, + message: Some(reason), + }); + } + match outcome.permission { + Some(AgentHookPermissionOutcome::Deny { reason }) => Some(PermissionRequestHookDecision { + allow: false, + message: reason, + }), + Some(AgentHookPermissionOutcome::Allow { reason }) => Some(PermissionRequestHookDecision { + allow: true, + message: reason, + }), + None => None, + } +} + +/// PostToolUse hooks run after a tool call completed. Blocking feedback and +/// `additionalContext` are appended to the tool result the model reads. +pub async fn dispatch_post_tool_use( + facts: NativeHookSessionFacts<'_>, + tool_name: &str, + tool_use_id: &str, + tool_input: &Value, + tool_response: &Value, +) -> PostToolUseHookDecision { + let mut decision = PostToolUseHookDecision::default(); + let Some(dispatch) = prepare(facts, AgentHookEvent::PostToolUse).await else { + return decision; + }; + let outcome = dispatch + .run(AgentHookEventPayload::PostToolUse { + tool_name: tool_name.to_string(), + tool_use_id: tool_use_id.to_string(), + tool_input: tool_input.clone(), + tool_response: tool_response.clone(), + }) + .await; + decision.block_reason = outcome.block_reason.clone(); + decision.additional_context = outcome.additional_context.clone(); + decision +} + +/// PreCompact hooks observe context compaction (`trigger`: `auto`|`manual`). +pub async fn dispatch_pre_compact(facts: NativeHookSessionFacts<'_>, trigger: &str) { + if let Some(dispatch) = prepare(facts, AgentHookEvent::PreCompact).await { + dispatch + .run(AgentHookEventPayload::PreCompact { + trigger: trigger.to_string(), + }) + .await; + } +} + +/// PostCompact hooks observe completed context compaction. +pub async fn dispatch_post_compact(facts: NativeHookSessionFacts<'_>, trigger: &str) { + if let Some(dispatch) = prepare(facts, AgentHookEvent::PostCompact).await { + dispatch + .run(AgentHookEventPayload::PostCompact { + trigger: trigger.to_string(), + }) + .await; + } +} + +/// SubagentStart hooks run when a subagent turn begins; plain stdout is +/// returned as model-visible context for the subagent. +pub async fn dispatch_subagent_start( + facts: NativeHookSessionFacts<'_>, + agent_id: &str, + agent_type: &str, +) -> Vec { + let Some(dispatch) = prepare(facts, AgentHookEvent::SubagentStart).await else { + return Vec::new(); + }; + let outcome = dispatch + .run(AgentHookEventPayload::SubagentStart { + agent_id: agent_id.to_string(), + agent_type: agent_type.to_string(), + }) + .await; + outcome.additional_context.clone() +} + +/// SubagentStop hooks run when a subagent turn settles. A blocking decision +/// is recorded (returned) but does not force the subagent to continue. +pub async fn dispatch_subagent_stop( + facts: NativeHookSessionFacts<'_>, + agent_id: &str, + agent_type: &str, + last_assistant_message: Option<&str>, +) -> Option { + let dispatch = prepare(facts, AgentHookEvent::SubagentStop).await?; + let outcome = dispatch + .run(AgentHookEventPayload::SubagentStop { + agent_id: agent_id.to_string(), + agent_type: agent_type.to_string(), + agent_transcript_path: None, + stop_hook_active: false, + last_assistant_message: last_assistant_message.map(str::to_string), + }) + .await; + outcome.block_reason.clone() +} + +/// Stop hooks run when the agent is about to finish a turn with a final +/// answer. A blocking decision returns the reason; the execution engine +/// injects it and continues the turn. +pub async fn dispatch_stop( + facts: NativeHookSessionFacts<'_>, + stop_hook_active: bool, + last_assistant_message: Option<&str>, +) -> Option { + let dispatch = prepare(facts, AgentHookEvent::Stop).await?; + let outcome = dispatch + .run(AgentHookEventPayload::Stop { + stop_hook_active, + last_assistant_message: last_assistant_message.map(str::to_string), + }) + .await; + outcome.block_reason.clone() +} + +/// SessionEnd hooks run when a session is deleted (`reason: "other"`). +/// Timeouts are capped tightly so deletion never hangs. +pub async fn dispatch_session_end(facts: NativeHookSessionFacts<'_>, reason: &str) { + pending_session_context().remove(facts.session_id); + if let Some(dispatch) = prepare(facts, AgentHookEvent::SessionEnd).await { + dispatch + .run(AgentHookEventPayload::SessionEnd { + reason: reason.to_string(), + }) + .await; + } +} + +/// Drop per-session hook state without dispatching anything. +pub fn clear_session_hook_state(session_id: &str) { + pending_session_context().remove(session_id); +} + +struct PreparedDispatch<'a> { + engine: Arc, + facts: NativeHookSessionFacts<'a>, + cwd: PathBuf, +} + +impl PreparedDispatch<'_> { + async fn run(&self, event: AgentHookEventPayload) -> AgentHookOutcome { + let payload = AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: self.facts.session_id.to_string(), + transcript_path: None, + cwd: self.cwd.to_string_lossy().to_string(), + model: self.facts.model.to_string(), + permission_mode: if self.facts.bypass_permissions { + AgentHookPermissionMode::BypassPermissions + } else { + AgentHookPermissionMode::Default + }, + turn_id: self.facts.turn_id.map(str::to_string), + }, + event, + }; + let event_name = payload.event(); + let outcome = self.engine.dispatch(&payload, &self.cwd).await; + for warning in &outcome.warnings { + warn!("Agent hook warning ({event_name}): {warning}"); + } + for message in &outcome.system_messages { + info!("Agent hook message ({event_name}): {message}"); + } + outcome + } +} + +/// Resolve the hook engine for this event, or `None` when hooks are +/// disabled, unavailable for this workspace, or have no matching rules. +async fn prepare<'a>( + facts: NativeHookSessionFacts<'a>, + event: AgentHookEvent, +) -> Option> { + if facts.is_remote_workspace { + debug!( + "Skipping agent hook dispatch for remote workspace: event={}, session_id={}", + event, facts.session_id + ); + return None; + } + let config = hooks_config().await; + if !config.enabled { + return None; + } + let engine = engine_for(facts.workspace_root, config.project_hooks_enabled).await?; + if !engine.has_rules(event) { + return None; + } + let cwd = facts + .workspace_root + .map(Path::to_path_buf) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_default(); + Some(PreparedDispatch { engine, facts, cwd }) +} + +/// Dot-path of the hook gates inside the settings document. Config paths +/// resolve against the serialized `GlobalConfig`, where `AppConfig` lives +/// under `app`. +pub(crate) const HOOKS_CONFIG_PATH: &str = "app.hooks"; + +async fn hooks_config() -> AgentHooksConfig { + match get_global_config_service().await { + Ok(service) => service + .get_config::(Some(HOOKS_CONFIG_PATH)) + .await + .unwrap_or_default(), + // Hosts without an initialized config service keep the defaults: + // user hooks on, project hooks off. + Err(_) => AgentHooksConfig::default(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HookFileFingerprint { + path: PathBuf, + modified: Option, + len: Option, +} + +fn fingerprint(path: PathBuf) -> HookFileFingerprint { + match std::fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => HookFileFingerprint { + modified: metadata.modified().ok(), + len: Some(metadata.len()), + path, + }, + _ => HookFileFingerprint { + modified: None, + len: None, + path, + }, + } +} + +struct CachedHookEngine { + engine: Arc, + fingerprints: Vec, + project_hooks_enabled: bool, +} + +type EngineCache = tokio::sync::Mutex, CachedHookEngine>>; + +fn engine_cache() -> &'static EngineCache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| tokio::sync::Mutex::new(BTreeMap::new())) +} + +fn pending_session_context() -> &'static DashMap> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(DashMap::new) +} + +/// Hook settings file paths for a workspace, in layer order (user first). +pub(crate) fn hook_settings_paths( + workspace_root: Option<&Path>, + project_hooks_enabled: bool, +) -> Vec<(AgentHookScope, PathBuf)> { + let mut paths = Vec::new(); + if let Ok(path_manager) = try_get_path_manager_arc() { + paths.push((AgentHookScope::User, path_manager.user_hooks_file())); + if project_hooks_enabled { + if let Some(workspace_root) = workspace_root { + paths.push(( + AgentHookScope::Project, + path_manager.project_hooks_file(workspace_root), + )); + } + } + } + paths +} + +/// Read each existing hook settings file, in the given layer order, and parse +/// them into one engine. Unreadable or oversized files are skipped with a +/// warning so one bad layer cannot disable the rest. +pub(crate) fn build_engine(paths: &[(AgentHookScope, PathBuf)]) -> AgentHookEngine { + let mut layers = Vec::new(); + for (scope, path) in paths { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => { + if metadata.len() > MAX_HOOKS_FILE_BYTES as u64 { + warn!( + "Ignoring hook configuration over the {} byte limit: {}", + MAX_HOOKS_FILE_BYTES, + path.display() + ); + continue; + } + match std::fs::read(path) { + Ok(bytes) => layers.push(AgentHookSettingsLayer { + scope: *scope, + source: path.to_string_lossy().to_string(), + bytes, + }), + Err(error) => warn!( + "Failed to read hook configuration: path={}, error={}", + path.display(), + error + ), + } + } + _ => {} + } + } + let (settings, issues) = AgentHookSettings::from_layers(&layers); + for issue in &issues { + warn!("Agent hook configuration issue: {issue}"); + } + AgentHookEngine::new(settings) +} + +async fn engine_for( + workspace_root: Option<&Path>, + project_hooks_enabled: bool, +) -> Option> { + let key = workspace_root.map(Path::to_path_buf); + let paths = hook_settings_paths(workspace_root, project_hooks_enabled); + if paths.is_empty() { + return None; + } + let fingerprints = paths + .iter() + .map(|(_, path)| fingerprint(path.clone())) + .collect::>(); + { + let cache = engine_cache().lock().await; + if let Some(cached) = cache.get(&key) { + if cached.fingerprints == fingerprints + && cached.project_hooks_enabled == project_hooks_enabled + { + return Some(Arc::clone(&cached.engine)); + } + } + } + + let engine = Arc::new(build_engine(&paths)); + let mut cache = engine_cache().lock().await; + if cache.len() >= MAX_CACHED_WORKSPACE_ENGINES && !cache.contains_key(&key) { + let oldest = cache.keys().next().cloned(); + if let Some(oldest) = oldest { + cache.remove(&oldest); + } + } + cache.insert( + key, + CachedHookEngine { + engine: Arc::clone(&engine), + fingerprints, + project_hooks_enabled, + }, + ); + Some(engine) +} diff --git a/src/crates/assembly/core/src/native_hooks_tests.rs b/src/crates/assembly/core/src/native_hooks_tests.rs new file mode 100644 index 0000000000..811b7cb138 --- /dev/null +++ b/src/crates/assembly/core/src/native_hooks_tests.rs @@ -0,0 +1,199 @@ +use crate::native_hooks::{ + build_engine, clear_session_hook_state, dispatch_pre_tool_use, hook_settings_paths, + take_pending_session_context, AgentHooksConfig, NativeHookSessionFacts, +}; +use bitfun_agent_runtime::native_hooks::{AgentHookEvent, AgentHookScope}; +use serde_json::json; +use std::path::{Path, PathBuf}; + +fn write_hooks_file(dir: &Path, name: &str, contents: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, contents).expect("hook fixture should be written"); + path +} + +#[test] +fn hooks_are_enabled_by_default_but_project_hooks_are_not() { + let config = AgentHooksConfig::default(); + assert!(config.enabled); + // Project hook files execute commands declared in the checked-out + // repository, so they stay opt-in. + assert!(!config.project_hooks_enabled); +} + +#[test] +fn hooks_config_deserializes_from_partial_settings() { + let config: AgentHooksConfig = + serde_json::from_value(json!({"project_hooks_enabled": true})).expect("partial config"); + assert!(config.enabled); + assert!(config.project_hooks_enabled); + + let disabled: AgentHooksConfig = + serde_json::from_value(json!({"enabled": false})).expect("partial config"); + assert!(!disabled.enabled); + assert!(!disabled.project_hooks_enabled); + + let empty: AgentHooksConfig = serde_json::from_value(json!({})).expect("empty config"); + assert_eq!(empty, AgentHooksConfig::default()); +} + +#[test] +fn hooks_config_resolves_at_the_documented_dot_path() { + // Config dot-paths resolve against the serialized GlobalConfig, so the + // gates live at `app.hooks` — not `hooks`. A wrong path here would make + // every lookup fall back to the defaults and silently ignore a user's + // `enabled: false`. + let global = crate::service::config::types::GlobalConfig::default(); + let serialized = serde_json::to_value(&global).expect("global config should serialize"); + + let mut current = &serialized; + for key in crate::native_hooks::HOOKS_CONFIG_PATH.split('.') { + current = current + .get(key) + .unwrap_or_else(|| panic!("config path segment '{key}' is missing")); + } + assert_eq!(current["enabled"], json!(true)); + assert_eq!(current["project_hooks_enabled"], json!(false)); + + let parsed: AgentHooksConfig = + serde_json::from_value(current.clone()).expect("gates should deserialize at that path"); + assert_eq!(parsed, AgentHooksConfig::default()); +} + +#[test] +fn user_settings_path_is_always_present_and_project_path_is_gated() { + let workspace = PathBuf::from("/tmp/example-workspace"); + + let without_project = hook_settings_paths(Some(&workspace), false); + assert_eq!(without_project.len(), 1); + assert_eq!(without_project[0].0, AgentHookScope::User); + assert!(without_project[0].1.ends_with("config/hooks.json")); + + let with_project = hook_settings_paths(Some(&workspace), true); + assert_eq!(with_project.len(), 2); + assert_eq!(with_project[0].0, AgentHookScope::User); + assert_eq!(with_project[1].0, AgentHookScope::Project); + assert_eq!( + with_project[1].1, + workspace.join(".bitfun/config/hooks.json") + ); + + // No workspace means no project layer even when project hooks are enabled. + let without_workspace = hook_settings_paths(None, true); + assert_eq!(without_workspace.len(), 1); + assert_eq!(without_workspace[0].0, AgentHookScope::User); +} + +#[test] +fn engine_loads_user_and_project_layers_in_order() { + let temp = tempfile::tempdir().expect("temp dir"); + let user = write_hooks_file( + temp.path(), + "user.json", + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"user-hook"}]}]}}"#, + ); + let project = write_hooks_file( + temp.path(), + "project.json", + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"project-hook"}]}]}}"#, + ); + + let engine = build_engine(&[ + (AgentHookScope::User, user), + (AgentHookScope::Project, project), + ]); + + let rules = engine.settings().rules_for(AgentHookEvent::PreToolUse); + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].scope, AgentHookScope::User); + assert_eq!(rules[0].handlers[0].command, "user-hook"); + assert_eq!(rules[1].scope, AgentHookScope::Project); + assert_eq!(rules[1].handlers[0].command, "project-hook"); +} + +#[test] +fn missing_settings_files_produce_an_empty_engine() { + let temp = tempfile::tempdir().expect("temp dir"); + let engine = build_engine(&[ + (AgentHookScope::User, temp.path().join("absent.json")), + ( + AgentHookScope::Project, + temp.path().join("also-absent.json"), + ), + ]); + + assert!(engine.is_empty()); + for event in AgentHookEvent::ALL { + assert!(!engine.has_rules(event)); + } +} + +#[test] +fn one_invalid_layer_does_not_disable_the_other() { + let temp = tempfile::tempdir().expect("temp dir"); + let broken = write_hooks_file(temp.path(), "broken.json", "{ not json"); + let good = write_hooks_file( + temp.path(), + "good.json", + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"still-runs"}]}]}}"#, + ); + + let engine = build_engine(&[ + (AgentHookScope::User, broken), + (AgentHookScope::Project, good), + ]); + + assert!(engine.has_rules(AgentHookEvent::Stop)); + assert_eq!( + engine.settings().rules_for(AgentHookEvent::Stop)[0].handlers[0].command, + "still-runs" + ); +} + +#[test] +fn oversized_settings_files_are_ignored() { + let temp = tempfile::tempdir().expect("temp dir"); + let padding = " ".repeat(1024 * 1024 + 1); + let oversized = write_hooks_file( + temp.path(), + "oversized.json", + &format!( + r#"{{"description":"{padding}","hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"too-big"}}]}}]}}}}"# + ), + ); + + let engine = build_engine(&[(AgentHookScope::User, oversized)]); + assert!(engine.is_empty()); +} + +#[tokio::test] +async fn remote_workspaces_skip_hook_dispatch() { + // Remote workspaces are skipped before any settings lookup, so this + // resolves to a no-op decision regardless of local configuration. + let decision = dispatch_pre_tool_use( + NativeHookSessionFacts { + session_id: "session-remote", + turn_id: Some("turn-1"), + workspace_root: Some(Path::new("/remote/workspace")), + is_remote_workspace: true, + model: "model-x", + bypass_permissions: false, + }, + "Bash", + "call-1", + &json!({"command": "ls"}), + ) + .await; + + assert!(decision.deny_reason.is_none()); + assert!(!decision.allow); + assert!(decision.updated_input.is_none()); +} + +#[test] +fn session_context_buffer_starts_empty_and_clears() { + assert!(take_pending_session_context("unknown-session").is_empty()); + // Clearing an unknown session is a no-op, not an error. + clear_session_hook_state("unknown-session"); + assert!(take_pending_session_context("unknown-session").is_empty()); +} diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index becd1cd06e..6d5d4b170b 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -136,6 +136,34 @@ pub struct AppConfig { /// Allowed values: "quit" | "minimize_to_tray" | "ask". #[serde(default = "default_close_button_behavior")] pub close_button_behavior: String, + /// Native agent lifecycle hooks (Codex-compatible hooks.json). + #[serde(default)] + pub hooks: AgentHooksConfig, +} + +/// Enablement gates for native agent hooks. +/// +/// Hook declarations themselves live in `hooks.json` documents (user scope: +/// `config/hooks.json` next to this file; project scope: +/// `{project}/.bitfun/config/hooks.json`), not in this settings document. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct AgentHooksConfig { + /// Master switch for native agent hooks. + pub enabled: bool, + /// Whether project-scope hook files are honored. Disabled by default + /// because project hook files execute commands from the checked-out + /// repository; enable only for workspaces you trust. + pub project_hooks_enabled: bool, +} + +impl Default for AgentHooksConfig { + fn default() -> Self { + Self { + enabled: true, + project_hooks_enabled: false, + } + } } /// Versioned user preference for grouping selectable Agent tools in the UI. @@ -1666,6 +1694,7 @@ impl Default for AppConfig { user_tool_groups: UserToolGroupsConfig::default(), user_skill_groups: UserSkillGroupsConfig::default(), close_button_behavior: default_close_button_behavior(), + hooks: AgentHooksConfig::default(), } } } diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index ee14058af4..ae0a9ed419 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -18,6 +18,7 @@ pub mod event_source; pub mod events; pub mod evidence_ledger; pub mod file_read_state; +pub mod native_hooks; pub mod output_surface; pub mod permission; pub mod post_call_hooks; diff --git a/src/crates/execution/agent-runtime/src/native_hooks/engine.rs b/src/crates/execution/agent-runtime/src/native_hooks/engine.rs new file mode 100644 index 0000000000..58748860d4 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/engine.rs @@ -0,0 +1,257 @@ +//! Hook dispatch: run matching command handlers for one lifecycle event. +//! +//! Process interface (Codex-compatible): +//! - The JSON payload is written to the handler's stdin. +//! - Exit code 0: stdout is interpreted as a JSON decision document when it +//! parses; otherwise, for events where plain stdout is context +//! (SessionStart, UserPromptSubmit, SubagentStart), the text becomes +//! model-visible context. +//! - Exit code 2: the event is blocked; stderr provides the blocking reason. +//! - Any other exit code, spawn failure, or timeout: a non-blocking warning. + +use super::output::{non_empty, AgentHookOutcome, RawHookOutput}; +use super::payload::AgentHookPayload; +use super::settings::{AgentHookEvent, AgentHookHandler, AgentHookSettings}; +use log::{debug, warn}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +/// Cap for a single model-visible hook text (reason or context). Larger +/// output is truncated with a marker, mirroring the Codex output budget. +pub const MAX_HOOK_MODEL_OUTPUT_BYTES: usize = 10_000; + +/// Cap for captured process output retained in memory. +const MAX_CAPTURED_OUTPUT_BYTES: usize = 1024 * 1024; + +/// Executes configured hooks for agent lifecycle events. +#[derive(Debug, Default)] +pub struct AgentHookEngine { + settings: AgentHookSettings, +} + +impl AgentHookEngine { + pub fn new(settings: AgentHookSettings) -> Self { + Self { settings } + } + + pub fn is_empty(&self) -> bool { + self.settings.is_empty() + } + + pub fn has_rules(&self, event: AgentHookEvent) -> bool { + self.settings.has_rules(event) + } + + pub fn settings(&self) -> &AgentHookSettings { + &self.settings + } + + /// Run every matching handler for the payload's event, sequentially in + /// configuration order (user layers before project layers), and fold + /// their results into one [`AgentHookOutcome`]. + pub async fn dispatch(&self, payload: &AgentHookPayload, cwd: &Path) -> AgentHookOutcome { + let event = payload.event(); + let mut outcome = AgentHookOutcome::default(); + let rules = self.settings.rules_for(event); + if rules.is_empty() { + return outcome; + } + let matcher_value = payload.event.matcher_value(); + let payload_json = payload.to_json().to_string(); + 'rules: for rule in rules { + if !rule.matcher.matches(matcher_value) { + continue; + } + for handler in &rule.handlers { + outcome.executed_handlers += 1; + let finalized = self + .run_and_apply(event, handler, &payload_json, cwd, &mut outcome) + .await; + if finalized { + break 'rules; + } + } + } + outcome + } + + /// Run one handler and fold its result into `outcome`. Returns `true` + /// when the dispatch is finalized (blocked or denied) and remaining + /// handlers must not run. + async fn run_and_apply( + &self, + event: AgentHookEvent, + handler: &AgentHookHandler, + payload_json: &str, + cwd: &Path, + outcome: &mut AgentHookOutcome, + ) -> bool { + let command = handler.effective_command(); + let timeout = handler.effective_timeout(event); + debug!( + "Running agent hook: event={}, command={}, timeout_ms={}", + event, + command, + timeout.as_millis() + ); + let run = run_hook_command(command, payload_json, cwd, timeout).await; + match run { + HookCommandRun::SpawnFailed(error) => { + outcome.warnings.push(format!( + "Hook '{command}' for {event} could not be started: {error}" + )); + false + } + HookCommandRun::TimedOut => { + outcome.warnings.push(format!( + "Hook '{command}' for {event} timed out after {}s and was killed", + timeout.as_secs() + )); + false + } + HookCommandRun::Completed { + exit_code, + stdout, + stderr, + } => match exit_code { + Some(0) => match serde_json::from_str::(stdout.trim()) { + Ok(output) => outcome.apply_output(output), + Err(_) => { + let text = stdout.trim(); + if !text.is_empty() && event.plain_stdout_is_context() { + outcome.additional_context.push(truncate_model_output(text)); + } + false + } + }, + Some(2) => { + let reason = non_empty(Some(stderr)).unwrap_or_else(|| { + format!("Hook '{command}' blocked this {event} event (exit code 2).") + }); + if outcome.block_reason.is_none() { + outcome.block_reason = Some(truncate_model_output(&reason)); + } + true + } + Some(code) => { + outcome.warnings.push(format!( + "Hook '{command}' for {event} exited with non-blocking code {code}" + )); + false + } + None => { + outcome.warnings.push(format!( + "Hook '{command}' for {event} was terminated by a signal" + )); + false + } + }, + } + } +} + +enum HookCommandRun { + Completed { + exit_code: Option, + stdout: String, + stderr: String, + }, + TimedOut, + SpawnFailed(String), +} + +async fn run_hook_command( + command: &str, + payload_json: &str, + cwd: &Path, + timeout: Duration, +) -> HookCommandRun { + let mut process = if cfg!(windows) { + let mut process = Command::new("cmd"); + process.arg("/C").arg(command); + process + } else { + let mut process = Command::new("sh"); + process.arg("-c").arg(command); + process + }; + if let Some(cwd) = existing_dir(cwd) { + process.current_dir(cwd); + } + process + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = match process.spawn() { + Ok(child) => child, + Err(error) => return HookCommandRun::SpawnFailed(error.to_string()), + }; + let mut stdin = child.stdin.take(); + + // The stdin write must be inside the timeout and must not be awaited to + // completion before the child is reaped: a handler that never reads stdin + // blocks the write once the payload exceeds the pipe buffer, and a + // handler that exits early makes the write fail with EPIPE. Driving the + // write concurrently with `wait_with_output` covers both, and the whole + // interaction is bounded by one timeout. + let interaction = async { + let write = async { + if let Some(mut stdin) = stdin.take() { + let _ = stdin.write_all(payload_json.as_bytes()).await; + let _ = stdin.shutdown().await; + } + }; + let (_, output) = tokio::join!(write, child.wait_with_output()); + output + }; + + match tokio::time::timeout(timeout, interaction).await { + Ok(Ok(output)) => HookCommandRun::Completed { + exit_code: output.status.code(), + stdout: bounded_lossy_string(output.stdout), + stderr: bounded_lossy_string(output.stderr), + }, + Ok(Err(error)) => HookCommandRun::SpawnFailed(error.to_string()), + // `kill_on_drop` reaps the child when the timeout drops the future. + Err(_) => HookCommandRun::TimedOut, + } +} + +fn existing_dir(path: &Path) -> Option { + if path.as_os_str().is_empty() { + return None; + } + if path.is_dir() { + Some(path.to_path_buf()) + } else { + warn!( + "Hook working directory does not exist; running without it: {}", + path.display() + ); + None + } +} + +fn bounded_lossy_string(mut bytes: Vec) -> String { + if bytes.len() > MAX_CAPTURED_OUTPUT_BYTES { + bytes.truncate(MAX_CAPTURED_OUTPUT_BYTES); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +/// Truncate model-visible hook text to the output budget, preserving UTF-8. +pub(crate) fn truncate_model_output(text: &str) -> String { + if text.len() <= MAX_HOOK_MODEL_OUTPUT_BYTES { + return text.to_string(); + } + let mut end = MAX_HOOK_MODEL_OUTPUT_BYTES; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + format!("{}\n[hook output truncated]", &text[..end]) +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/mod.rs b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs new file mode 100644 index 0000000000..e20a8e39c3 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs @@ -0,0 +1,33 @@ +//! Native BitFun agent lifecycle hooks. +//! +//! This module owns the portable hook engine that executes user-configured +//! command hooks at agent lifecycle events. The configuration document, event +//! names, process interface (stdin JSON payload, exit-code semantics, stdout +//! decision schema), matcher semantics, and timeout defaults are kept +//! consistent with Codex hooks so users can reuse existing hook scripts. +//! +//! The engine is host-independent: it receives already-loaded settings layers +//! and fully-built payloads, and never resolves BitFun config paths itself. +//! Config discovery, scope gating, and dispatch-site integration live in +//! `bitfun-core` (`native_hooks` wiring). +//! +//! Distinct from: +//! - `post_call_hooks`: internal compiled-in Rust hooks (not user-configured). +//! - the external hook catalog (`bitfun-product-domains`): read-only +//! inspection of other AI applications' hook configuration. + +mod engine; +mod output; +mod payload; +mod settings; + +pub use engine::{AgentHookEngine, MAX_HOOK_MODEL_OUTPUT_BYTES}; +pub use output::{AgentHookOutcome, AgentHookPermissionOutcome}; +pub use payload::{ + AgentHookEventPayload, AgentHookPayload, AgentHookPayloadCommon, AgentHookPermissionMode, +}; +pub use settings::{ + AgentHookEvent, AgentHookHandler, AgentHookMatcher, AgentHookRule, AgentHookScope, + AgentHookSettings, AgentHookSettingsIssue, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, + MAX_HOOK_HANDLERS, +}; diff --git a/src/crates/execution/agent-runtime/src/native_hooks/output.rs b/src/crates/execution/agent-runtime/src/native_hooks/output.rs new file mode 100644 index 0000000000..a78a47d091 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/output.rs @@ -0,0 +1,180 @@ +//! Hook stdout decision schema and aggregated dispatch outcome. +//! +//! On exit code 0 a hook may print a JSON object using the Codex output +//! schema (`continue`, `stopReason`, `systemMessage`, `suppressOutput`, +//! `decision`/`reason`, and per-event `hookSpecificOutput`). Unknown fields +//! are tolerated for forward compatibility. + +use serde::Deserialize; +use serde_json::Value; + +/// Raw stdout JSON as printed by a hook process (all fields optional). +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct RawHookOutput { + #[serde(rename = "continue")] + pub continue_: Option, + #[serde(rename = "stopReason")] + pub stop_reason: Option, + #[serde(rename = "systemMessage")] + pub system_message: Option, + #[serde(rename = "suppressOutput")] + pub suppress_output: Option, + /// Legacy/common decision field: `"block"` blocks the event. + pub decision: Option, + pub reason: Option, + #[serde(rename = "hookSpecificOutput")] + pub hook_specific_output: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct RawHookSpecificOutput { + #[serde(rename = "hookEventName")] + #[allow(dead_code)] + pub hook_event_name: Option, + /// PreToolUse: `"allow"` | `"deny"`. + #[serde(rename = "permissionDecision")] + pub permission_decision: Option, + #[serde(rename = "permissionDecisionReason")] + pub permission_decision_reason: Option, + /// PreToolUse: replacement tool input. + #[serde(rename = "updatedInput")] + pub updated_input: Option, + /// PostToolUse (and others): extra model-visible context. + #[serde(rename = "additionalContext")] + pub additional_context: Option, + /// PermissionRequest: `{ "behavior": "allow"|"deny", "message": "..." }`. + pub decision: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct RawPermissionRequestDecision { + pub behavior: Option, + pub message: Option, +} + +/// Permission-shaped decision produced by PreToolUse `permissionDecision` +/// or PermissionRequest `decision.behavior`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentHookPermissionOutcome { + Allow { reason: Option }, + Deny { reason: Option }, +} + +/// Aggregated result of dispatching one event across all matching handlers. +/// +/// Merge rules: the first blocking decision wins and stops later handlers; +/// a permission `deny` overrides an earlier `allow`; `updatedInput` from a +/// later handler replaces an earlier one; context and system messages are +/// collected from every handler. +#[derive(Debug, Clone, Default)] +pub struct AgentHookOutcome { + /// Set when a handler blocked the event (`decision: "block"` or exit + /// code 2, whose stderr becomes the reason). + pub block_reason: Option, + pub permission: Option, + pub updated_input: Option, + /// Model-visible context: `additionalContext` plus, for events where + /// plain stdout is context, non-JSON stdout text. + pub additional_context: Vec, + /// User-facing messages (`systemMessage`). + pub system_messages: Vec, + /// Set when a handler asked to stop the whole turn (`continue: false`). + pub stop_reason: Option, + /// Non-blocking handler problems (spawn failure, timeout, non-zero + /// non-blocking exit codes, unparsable output). + pub warnings: Vec, + pub suppress_output: bool, + /// Number of handlers that were actually spawned. + pub executed_handlers: usize, +} + +impl AgentHookOutcome { + pub fn is_blocked(&self) -> bool { + self.block_reason.is_some() + } + + pub fn permission_denied(&self) -> bool { + matches!( + self.permission, + Some(AgentHookPermissionOutcome::Deny { .. }) + ) + } + + /// Fold one parsed stdout document into the aggregate. Returns `true` + /// when dispatch should stop running further handlers (a final blocking + /// or denying decision was made). + pub(crate) fn apply_output(&mut self, output: RawHookOutput) -> bool { + let mut finalized = false; + if let Some(message) = non_empty(output.system_message) { + self.system_messages.push(message); + } + if output.suppress_output == Some(true) { + self.suppress_output = true; + } + if output.continue_ == Some(false) && self.stop_reason.is_none() { + self.stop_reason = Some( + non_empty(output.stop_reason) + .unwrap_or_else(|| "A hook requested to stop this turn.".to_string()), + ); + } + if output.decision.as_deref() == Some("block") && self.block_reason.is_none() { + self.block_reason = Some( + non_empty(output.reason) + .unwrap_or_else(|| "A hook blocked this event.".to_string()), + ); + finalized = true; + } + if let Some(specific) = output.hook_specific_output { + if let Some(context) = non_empty(specific.additional_context) { + self.additional_context.push(context); + } + if let Some(updated_input) = specific.updated_input { + self.updated_input = Some(updated_input); + } + // PreToolUse uses `permissionDecision`; PermissionRequest uses + // `decision.behavior`. Both carry the same allow/deny vocabulary. + finalized |= self.apply_permission_decision( + specific.permission_decision.as_deref(), + non_empty(specific.permission_decision_reason), + ); + if let Some(decision) = specific.decision { + finalized |= self.apply_permission_decision( + decision.behavior.as_deref(), + non_empty(decision.message), + ); + } + } + finalized + } + + /// Apply one allow/deny decision. A deny is final and outranks any + /// earlier allow. Returns `true` when the dispatch is finalized. + fn apply_permission_decision( + &mut self, + behavior: Option<&str>, + reason: Option, + ) -> bool { + match behavior { + Some("deny") => { + self.permission = Some(AgentHookPermissionOutcome::Deny { reason }); + true + } + Some("allow") if !self.permission_denied() => { + self.permission = Some(AgentHookPermissionOutcome::Allow { reason }); + false + } + _ => false, + } + } +} + +/// Normalize one hook-supplied string: trim it, drop it when empty, and hold +/// it to the model-visible output budget. Every decision field a hook can +/// surface to the model or the operator passes through here, so the budget +/// applies to JSON-supplied text exactly as it does to plain stdout. +pub(crate) fn non_empty(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| super::engine::truncate_model_output(&value)) +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/payload.rs b/src/crates/execution/agent-runtime/src/native_hooks/payload.rs new file mode 100644 index 0000000000..b8467f0e79 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/payload.rs @@ -0,0 +1,252 @@ +//! Stdin payload construction. +//! +//! Field names follow the Codex hook process interface exactly. Every event +//! payload carries the common fields (`session_id`, `transcript_path`, `cwd`, +//! `hook_event_name`, `model`, `permission_mode`), turn-scoped events add +//! `turn_id`, and each event contributes its documented event-specific fields. + +use super::settings::AgentHookEvent; +use serde_json::{json, Map, Value}; + +/// Codex permission-mode vocabulary carried in every payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentHookPermissionMode { + #[default] + Default, + AcceptEdits, + Plan, + DontAsk, + BypassPermissions, +} + +impl AgentHookPermissionMode { + pub const fn as_str(self) -> &'static str { + match self { + AgentHookPermissionMode::Default => "default", + AgentHookPermissionMode::AcceptEdits => "acceptEdits", + AgentHookPermissionMode::Plan => "plan", + AgentHookPermissionMode::DontAsk => "dontAsk", + AgentHookPermissionMode::BypassPermissions => "bypassPermissions", + } + } +} + +/// Fields shared by every hook payload. +#[derive(Debug, Clone)] +pub struct AgentHookPayloadCommon { + pub session_id: String, + /// Session transcript path when available; serialized as `null` otherwise. + pub transcript_path: Option, + /// Working directory the hook observes and runs in. + pub cwd: String, + pub model: String, + pub permission_mode: AgentHookPermissionMode, + /// Present for turn-scoped events. + pub turn_id: Option, +} + +/// Event-specific payload fields. +#[derive(Debug, Clone)] +pub enum AgentHookEventPayload { + SessionStart { + /// `startup` | `resume` | `clear` | `compact` + source: String, + }, + SessionEnd { + reason: String, + }, + SubagentStart { + agent_id: String, + agent_type: String, + }, + PreToolUse { + tool_name: String, + tool_use_id: String, + tool_input: Value, + }, + PermissionRequest { + tool_name: String, + tool_input: Value, + }, + PostToolUse { + tool_name: String, + tool_use_id: String, + tool_input: Value, + tool_response: Value, + }, + PreCompact { + /// `manual` | `auto` + trigger: String, + }, + PostCompact { + trigger: String, + }, + UserPromptSubmit { + prompt: String, + }, + SubagentStop { + agent_id: String, + agent_type: String, + agent_transcript_path: Option, + stop_hook_active: bool, + last_assistant_message: Option, + }, + Stop { + stop_hook_active: bool, + last_assistant_message: Option, + }, +} + +impl AgentHookEventPayload { + pub const fn event(&self) -> AgentHookEvent { + match self { + AgentHookEventPayload::SessionStart { .. } => AgentHookEvent::SessionStart, + AgentHookEventPayload::SessionEnd { .. } => AgentHookEvent::SessionEnd, + AgentHookEventPayload::SubagentStart { .. } => AgentHookEvent::SubagentStart, + AgentHookEventPayload::PreToolUse { .. } => AgentHookEvent::PreToolUse, + AgentHookEventPayload::PermissionRequest { .. } => AgentHookEvent::PermissionRequest, + AgentHookEventPayload::PostToolUse { .. } => AgentHookEvent::PostToolUse, + AgentHookEventPayload::PreCompact { .. } => AgentHookEvent::PreCompact, + AgentHookEventPayload::PostCompact { .. } => AgentHookEvent::PostCompact, + AgentHookEventPayload::UserPromptSubmit { .. } => AgentHookEvent::UserPromptSubmit, + AgentHookEventPayload::SubagentStop { .. } => AgentHookEvent::SubagentStop, + AgentHookEventPayload::Stop { .. } => AgentHookEvent::Stop, + } + } + + /// The value matchers are evaluated against for this event, when the + /// event supports matcher filtering. + pub fn matcher_value(&self) -> Option<&str> { + match self { + AgentHookEventPayload::PreToolUse { tool_name, .. } + | AgentHookEventPayload::PermissionRequest { tool_name, .. } + | AgentHookEventPayload::PostToolUse { tool_name, .. } => Some(tool_name), + AgentHookEventPayload::SubagentStart { agent_type, .. } + | AgentHookEventPayload::SubagentStop { agent_type, .. } => Some(agent_type), + AgentHookEventPayload::PreCompact { trigger } + | AgentHookEventPayload::PostCompact { trigger } => Some(trigger), + AgentHookEventPayload::SessionStart { source } => Some(source), + AgentHookEventPayload::SessionEnd { .. } + | AgentHookEventPayload::UserPromptSubmit { .. } + | AgentHookEventPayload::Stop { .. } => None, + } + } +} + +/// A fully-built payload ready to serialize onto a hook's stdin. +#[derive(Debug, Clone)] +pub struct AgentHookPayload { + pub common: AgentHookPayloadCommon, + pub event: AgentHookEventPayload, +} + +impl AgentHookPayload { + pub const fn event(&self) -> AgentHookEvent { + self.event.event() + } + + pub fn to_json(&self) -> Value { + let event = self.event(); + let mut fields = Map::new(); + fields.insert("session_id".into(), json!(self.common.session_id)); + fields.insert( + "transcript_path".into(), + match &self.common.transcript_path { + Some(path) => json!(path), + None => Value::Null, + }, + ); + fields.insert("cwd".into(), json!(self.common.cwd)); + fields.insert("hook_event_name".into(), json!(event.as_str())); + fields.insert("model".into(), json!(self.common.model)); + fields.insert( + "permission_mode".into(), + json!(self.common.permission_mode.as_str()), + ); + if event.is_turn_scoped() { + if let Some(turn_id) = &self.common.turn_id { + fields.insert("turn_id".into(), json!(turn_id)); + } + } + match &self.event { + AgentHookEventPayload::SessionStart { source } => { + fields.insert("source".into(), json!(source)); + } + AgentHookEventPayload::SessionEnd { reason } => { + fields.insert("reason".into(), json!(reason)); + } + AgentHookEventPayload::SubagentStart { + agent_id, + agent_type, + } => { + fields.insert("agent_id".into(), json!(agent_id)); + fields.insert("agent_type".into(), json!(agent_type)); + } + AgentHookEventPayload::PreToolUse { + tool_name, + tool_use_id, + tool_input, + } => { + fields.insert("tool_name".into(), json!(tool_name)); + fields.insert("tool_use_id".into(), json!(tool_use_id)); + fields.insert("tool_input".into(), tool_input.clone()); + } + AgentHookEventPayload::PermissionRequest { + tool_name, + tool_input, + } => { + fields.insert("tool_name".into(), json!(tool_name)); + fields.insert("tool_input".into(), tool_input.clone()); + } + AgentHookEventPayload::PostToolUse { + tool_name, + tool_use_id, + tool_input, + tool_response, + } => { + fields.insert("tool_name".into(), json!(tool_name)); + fields.insert("tool_use_id".into(), json!(tool_use_id)); + fields.insert("tool_input".into(), tool_input.clone()); + fields.insert("tool_response".into(), tool_response.clone()); + } + AgentHookEventPayload::PreCompact { trigger } + | AgentHookEventPayload::PostCompact { trigger } => { + fields.insert("trigger".into(), json!(trigger)); + } + AgentHookEventPayload::UserPromptSubmit { prompt } => { + fields.insert("prompt".into(), json!(prompt)); + } + AgentHookEventPayload::SubagentStop { + agent_id, + agent_type, + agent_transcript_path, + stop_hook_active, + last_assistant_message, + } => { + fields.insert("agent_id".into(), json!(agent_id)); + fields.insert("agent_type".into(), json!(agent_type)); + fields.insert( + "agent_transcript_path".into(), + match agent_transcript_path { + Some(path) => json!(path), + None => Value::Null, + }, + ); + fields.insert("stop_hook_active".into(), json!(stop_hook_active)); + if let Some(message) = last_assistant_message { + fields.insert("last_assistant_message".into(), json!(message)); + } + } + AgentHookEventPayload::Stop { + stop_hook_active, + last_assistant_message, + } => { + fields.insert("stop_hook_active".into(), json!(stop_hook_active)); + if let Some(message) = last_assistant_message { + fields.insert("last_assistant_message".into(), json!(message)); + } + } + } + Value::Object(fields) + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/settings.rs b/src/crates/execution/agent-runtime/src/native_hooks/settings.rs new file mode 100644 index 0000000000..5f5d185c9e --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/settings.rs @@ -0,0 +1,613 @@ +//! Hook settings document parsing. +//! +//! BitFun reads the same `hooks.json` document shape as Codex: +//! +//! ```json +//! { +//! "description": "optional", +//! "hooks": { +//! "PreToolUse": [ +//! { +//! "matcher": "Bash", +//! "hooks": [ +//! { "type": "command", "command": "python3 check.py", "timeout": 30 } +//! ] +//! } +//! ] +//! } +//! } +//! ``` +//! +//! Validation mirrors the Codex rules: the JSON root may only contain +//! `description` and `hooks`; event names come from the fixed Codex event +//! list; unknown events are dropped with a diagnostic while valid events +//! survive; only `type: "command"` handlers are executable (`prompt` and +//! `agent` are recognized but skipped as unsupported). + +use regex::Regex; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fmt; +use std::time::Duration; + +/// Maximum size of one hooks configuration file (matches the 1 MiB Codex +/// static-inspection bound used elsewhere in this repository). +pub const MAX_HOOKS_FILE_BYTES: usize = 1024 * 1024; +/// Maximum executable handlers accepted across all configuration layers. +pub const MAX_HOOK_HANDLERS: usize = 2048; +const MAX_MATCHER_BYTES: usize = 512; + +/// The fixed Codex-compatible hook event list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AgentHookEvent { + PreToolUse, + PermissionRequest, + PostToolUse, + PreCompact, + PostCompact, + SessionStart, + SessionEnd, + UserPromptSubmit, + SubagentStart, + SubagentStop, + Stop, +} + +impl AgentHookEvent { + pub const ALL: [AgentHookEvent; 11] = [ + AgentHookEvent::PreToolUse, + AgentHookEvent::PermissionRequest, + AgentHookEvent::PostToolUse, + AgentHookEvent::PreCompact, + AgentHookEvent::PostCompact, + AgentHookEvent::SessionStart, + AgentHookEvent::SessionEnd, + AgentHookEvent::UserPromptSubmit, + AgentHookEvent::SubagentStart, + AgentHookEvent::SubagentStop, + AgentHookEvent::Stop, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + AgentHookEvent::PreToolUse => "PreToolUse", + AgentHookEvent::PermissionRequest => "PermissionRequest", + AgentHookEvent::PostToolUse => "PostToolUse", + AgentHookEvent::PreCompact => "PreCompact", + AgentHookEvent::PostCompact => "PostCompact", + AgentHookEvent::SessionStart => "SessionStart", + AgentHookEvent::SessionEnd => "SessionEnd", + AgentHookEvent::UserPromptSubmit => "UserPromptSubmit", + AgentHookEvent::SubagentStart => "SubagentStart", + AgentHookEvent::SubagentStop => "SubagentStop", + AgentHookEvent::Stop => "Stop", + } + } + + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|event| event.as_str() == name) + } + + /// Events whose stdin payload carries `turn_id`. + pub const fn is_turn_scoped(self) -> bool { + !matches!( + self, + AgentHookEvent::SessionStart | AgentHookEvent::SessionEnd + ) + } + + /// Default handler timeout in seconds (Codex: 600s, SessionEnd 1s). + pub const fn default_timeout_secs(self) -> u64 { + match self { + AgentHookEvent::SessionEnd => 1, + _ => 600, + } + } + + /// Hard cap for a configured handler timeout (Codex caps SessionEnd at 3s). + pub const fn max_timeout_secs(self) -> Option { + match self { + AgentHookEvent::SessionEnd => Some(3), + _ => None, + } + } + + /// Whether plain (non-JSON) stdout of a successful handler becomes + /// model-visible context for this event. + pub const fn plain_stdout_is_context(self) -> bool { + matches!( + self, + AgentHookEvent::SessionStart + | AgentHookEvent::UserPromptSubmit + | AgentHookEvent::SubagentStart + ) + } +} + +impl fmt::Display for AgentHookEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Where a hook rule was declared. User-scope rules run before project-scope +/// rules, matching the Codex layer order (user configuration first). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentHookScope { + User, + Project, +} + +impl AgentHookScope { + pub const fn as_str(self) -> &'static str { + match self { + AgentHookScope::User => "user", + AgentHookScope::Project => "project", + } + } +} + +/// One executable `type: "command"` handler. +#[derive(Debug, Clone)] +pub struct AgentHookHandler { + pub command: String, + /// Optional Windows override for `command` (`commandWindows`). + pub command_windows: Option, + /// Optional timeout in seconds (`timeout`). + pub timeout_seconds: Option, + /// Optional UI text shown while the hook runs (`statusMessage`). + pub status_message: Option, +} + +impl AgentHookHandler { + pub fn effective_command(&self) -> &str { + if cfg!(windows) { + if let Some(command) = self + .command_windows + .as_deref() + .map(str::trim) + .filter(|command| !command.is_empty()) + { + return command; + } + } + &self.command + } + + pub fn effective_timeout(&self, event: AgentHookEvent) -> Duration { + let mut seconds = self + .timeout_seconds + .unwrap_or_else(|| event.default_timeout_secs()); + if seconds == 0 { + seconds = event.default_timeout_secs(); + } + if let Some(cap) = event.max_timeout_secs() { + seconds = seconds.min(cap); + } + Duration::from_secs(seconds) + } +} + +/// Codex matcher semantics: absent, empty, or `"*"` matches everything; +/// any other string is a regular expression that must match the whole +/// matcher value (so `Bash` is an exact tool-name match, `Edit|Write` +/// matches either name, and `mcp__filesystem__.*` matches by prefix). +/// A malformed matcher never matches anything. +#[derive(Debug, Clone)] +pub enum AgentHookMatcher { + Any, + Pattern { raw: String, regex: Option }, + Invalid { raw: String }, +} + +impl AgentHookMatcher { + fn from_value(value: Option<&Value>) -> (Self, bool) { + let Some(value) = value else { + return (AgentHookMatcher::Any, true); + }; + let Some(raw) = value.as_str() else { + return ( + AgentHookMatcher::Invalid { + raw: value.to_string(), + }, + false, + ); + }; + if raw.is_empty() || raw == "*" { + return (AgentHookMatcher::Any, true); + } + if raw.len() > MAX_MATCHER_BYTES || raw.chars().any(char::is_control) { + return ( + AgentHookMatcher::Invalid { + raw: raw.to_string(), + }, + false, + ); + } + let regex = Regex::new(&format!("^(?:{raw})$")).ok(); + let valid = regex.is_some(); + ( + AgentHookMatcher::Pattern { + raw: raw.to_string(), + regex, + }, + valid, + ) + } + + /// `value` is the event's matcher context (tool name, agent type, + /// compaction trigger, or session-start source). Events without a matcher + /// context pass `None`, which ignores configured patterns (Codex applies + /// no filtering for those events). + pub fn matches(&self, value: Option<&str>) -> bool { + match self { + AgentHookMatcher::Any => true, + AgentHookMatcher::Pattern { regex, .. } => match (regex, value) { + (Some(regex), Some(value)) => regex.is_match(value), + (Some(_), None) => true, + (None, _) => false, + }, + AgentHookMatcher::Invalid { .. } => false, + } + } + + pub fn display(&self) -> &str { + match self { + AgentHookMatcher::Any => "*", + AgentHookMatcher::Pattern { raw, .. } => raw, + AgentHookMatcher::Invalid { raw } => raw, + } + } +} + +/// One matcher group from the configuration document. +#[derive(Debug, Clone)] +pub struct AgentHookRule { + pub matcher: AgentHookMatcher, + pub handlers: Vec, + pub scope: AgentHookScope, + /// User-recognizable source location (for diagnostics/logs). + pub source: String, +} + +/// A configuration layer handed to [`AgentHookSettings::from_layers`]. +#[derive(Debug, Clone)] +pub struct AgentHookSettingsLayer { + pub scope: AgentHookScope, + /// User-recognizable source location (for diagnostics/logs). + pub source: String, + pub bytes: Vec, +} + +/// Non-fatal problems found while parsing hook settings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentHookSettingsIssue { + /// Whole document rejected: not valid JSON, not an object, or the root + /// contains keys other than `description` and `hooks`. + DocumentInvalid { + source: String, + }, + FileTooLarge { + source: String, + }, + EventNameUnsupported { + source: String, + event: String, + }, + EventInvalid { + source: String, + event: String, + }, + GroupInvalid { + source: String, + event: String, + }, + HandlerInvalid { + source: String, + event: String, + }, + HandlerUnsupported { + source: String, + event: String, + handler_type: String, + }, + HandlerLimitExceeded { + source: String, + }, + MatcherInvalid { + source: String, + event: String, + matcher: String, + }, +} + +impl fmt::Display for AgentHookSettingsIssue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AgentHookSettingsIssue::DocumentInvalid { source } => write!( + f, + "Hook configuration could not be parsed (root may only contain 'description' and 'hooks'): {source}" + ), + AgentHookSettingsIssue::FileTooLarge { source } => write!( + f, + "Hook configuration exceeds the {} byte limit: {source}", + MAX_HOOKS_FILE_BYTES + ), + AgentHookSettingsIssue::EventNameUnsupported { source, event } => write!( + f, + "Hook event '{event}' is not a supported event name: {source}" + ), + AgentHookSettingsIssue::EventInvalid { source, event } => write!( + f, + "Hook event '{event}' must contain an array of matcher groups: {source}" + ), + AgentHookSettingsIssue::GroupInvalid { source, event } => write!( + f, + "Hook matcher group under '{event}' must be an object with a 'hooks' array: {source}" + ), + AgentHookSettingsIssue::HandlerInvalid { source, event } => write!( + f, + "Hook handler under '{event}' is missing a supported 'type' or a required field: {source}" + ), + AgentHookSettingsIssue::HandlerUnsupported { + source, + event, + handler_type, + } => write!( + f, + "Hook handler type '{handler_type}' under '{event}' is recognized but not executable by BitFun; only 'command' handlers run: {source}" + ), + AgentHookSettingsIssue::HandlerLimitExceeded { source } => write!( + f, + "Additional hook handlers were ignored after the {MAX_HOOK_HANDLERS} handler limit: {source}" + ), + AgentHookSettingsIssue::MatcherInvalid { + source, + event, + matcher, + } => write!( + f, + "Hook matcher '{matcher}' under '{event}' is not a valid pattern and will never match: {source}" + ), + } + } +} + +/// Parsed, merged hook settings across all configuration layers. +#[derive(Debug, Default)] +pub struct AgentHookSettings { + rules: BTreeMap>, +} + +impl AgentHookSettings { + pub fn from_layers(layers: &[AgentHookSettingsLayer]) -> (Self, Vec) { + let mut settings = AgentHookSettings::default(); + let mut issues = Vec::new(); + let mut remaining_handlers = MAX_HOOK_HANDLERS; + for layer in layers { + parse_layer(layer, &mut settings, &mut issues, &mut remaining_handlers); + } + (settings, issues) + } + + pub fn is_empty(&self) -> bool { + self.rules.values().all(|rules| rules.is_empty()) + } + + pub fn rules_for(&self, event: AgentHookEvent) -> &[AgentHookRule] { + self.rules + .get(&event) + .map(Vec::as_slice) + .unwrap_or_default() + } + + pub fn has_rules(&self, event: AgentHookEvent) -> bool { + !self.rules_for(event).is_empty() + } + + pub fn total_handlers(&self) -> usize { + self.rules + .values() + .flatten() + .map(|rule| rule.handlers.len()) + .sum() + } +} + +fn parse_layer( + layer: &AgentHookSettingsLayer, + settings: &mut AgentHookSettings, + issues: &mut Vec, + remaining_handlers: &mut usize, +) { + if layer.bytes.len() > MAX_HOOKS_FILE_BYTES { + issues.push(AgentHookSettingsIssue::FileTooLarge { + source: layer.source.clone(), + }); + return; + } + let Ok(root) = serde_json::from_slice::(&layer.bytes) else { + issues.push(AgentHookSettingsIssue::DocumentInvalid { + source: layer.source.clone(), + }); + return; + }; + let Value::Object(root) = root else { + issues.push(AgentHookSettingsIssue::DocumentInvalid { + source: layer.source.clone(), + }); + return; + }; + // Codex rejects the whole hooks.json document when the root carries any + // key besides `description` and `hooks`. + if root + .keys() + .any(|key| key != "description" && key != "hooks") + { + issues.push(AgentHookSettingsIssue::DocumentInvalid { + source: layer.source.clone(), + }); + return; + } + let Some(events) = root.get("hooks") else { + return; + }; + let Value::Object(events) = events else { + issues.push(AgentHookSettingsIssue::DocumentInvalid { + source: layer.source.clone(), + }); + return; + }; + for (event_name, groups) in events { + // `hooks.state` is a reserved Codex table, never an event. + if event_name == "state" { + continue; + } + let Some(event) = AgentHookEvent::parse(event_name) else { + issues.push(AgentHookSettingsIssue::EventNameUnsupported { + source: layer.source.clone(), + event: event_name.clone(), + }); + continue; + }; + let Value::Array(groups) = groups else { + issues.push(AgentHookSettingsIssue::EventInvalid { + source: layer.source.clone(), + event: event_name.clone(), + }); + continue; + }; + for group in groups { + let Value::Object(group) = group else { + issues.push(AgentHookSettingsIssue::GroupInvalid { + source: layer.source.clone(), + event: event_name.clone(), + }); + continue; + }; + let Some(Value::Array(handlers)) = group.get("hooks") else { + issues.push(AgentHookSettingsIssue::GroupInvalid { + source: layer.source.clone(), + event: event_name.clone(), + }); + continue; + }; + let (matcher, matcher_valid) = AgentHookMatcher::from_value(group.get("matcher")); + if !matcher_valid { + issues.push(AgentHookSettingsIssue::MatcherInvalid { + source: layer.source.clone(), + event: event_name.clone(), + matcher: matcher.display().to_string(), + }); + } + let mut parsed_handlers = Vec::new(); + for handler in handlers { + if *remaining_handlers == 0 { + if !issues.iter().any(|issue| { + matches!( + issue, + AgentHookSettingsIssue::HandlerLimitExceeded { source } + if *source == layer.source + ) + }) { + issues.push(AgentHookSettingsIssue::HandlerLimitExceeded { + source: layer.source.clone(), + }); + } + break; + } + match parse_handler(handler) { + ParsedHandler::Command(parsed) => { + *remaining_handlers -= 1; + parsed_handlers.push(parsed); + } + ParsedHandler::Unsupported(handler_type) => { + *remaining_handlers = remaining_handlers.saturating_sub(1); + issues.push(AgentHookSettingsIssue::HandlerUnsupported { + source: layer.source.clone(), + event: event_name.clone(), + handler_type, + }); + } + ParsedHandler::Invalid => { + *remaining_handlers = remaining_handlers.saturating_sub(1); + issues.push(AgentHookSettingsIssue::HandlerInvalid { + source: layer.source.clone(), + event: event_name.clone(), + }); + } + } + } + if parsed_handlers.is_empty() { + continue; + } + settings + .rules + .entry(event) + .or_default() + .push(AgentHookRule { + matcher: matcher.clone(), + handlers: parsed_handlers, + scope: layer.scope, + source: layer.source.clone(), + }); + } + } +} + +enum ParsedHandler { + Command(AgentHookHandler), + Unsupported(String), + Invalid, +} + +fn parse_handler(handler: &Value) -> ParsedHandler { + let Value::Object(handler) = handler else { + return ParsedHandler::Invalid; + }; + let Some(handler_type) = handler.get("type").and_then(Value::as_str) else { + return ParsedHandler::Invalid; + }; + match handler_type { + "command" => {} + // Codex recognizes prompt/agent declarations but they are not + // native command handlers; BitFun skips them the same way. + "prompt" | "agent" => return ParsedHandler::Unsupported(handler_type.to_string()), + _ => return ParsedHandler::Invalid, + } + let Some(command) = handler + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + else { + return ParsedHandler::Invalid; + }; + let timeout_seconds = match handler.get("timeout") { + None | Some(Value::Null) => None, + Some(value) => match value.as_u64().filter(|timeout| *timeout > 0) { + Some(timeout) => Some(timeout), + None => return ParsedHandler::Invalid, + }, + }; + let command_windows = match handler.get("commandWindows") { + None | Some(Value::Null) => None, + Some(value) => match value.as_str() { + Some(command) => Some(command.to_string()), + None => return ParsedHandler::Invalid, + }, + }; + let status_message = match handler.get("statusMessage") { + None | Some(Value::Null) => None, + Some(value) => match value.as_str() { + Some(message) => Some(message.to_string()), + None => return ParsedHandler::Invalid, + }, + }; + ParsedHandler::Command(AgentHookHandler { + command: command.to_string(), + command_windows, + timeout_seconds, + status_message, + }) +} diff --git a/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs b/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs new file mode 100644 index 0000000000..9c85ae9e64 --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs @@ -0,0 +1,497 @@ +//! Native agent hook process-interface contracts. +//! +//! These tests spawn real hook commands to pin the Codex process contract: +//! the payload arrives on stdin, exit code 0 interprets stdout JSON, exit +//! code 2 blocks with stderr as the reason, other codes warn without +//! blocking, and timeouts kill the handler. +//! +//! Unix-only: the fixtures are `sh` one-liners. +#![cfg(unix)] + +use bitfun_agent_runtime::native_hooks::{ + AgentHookEngine, AgentHookEventPayload, AgentHookOutcome, AgentHookPayload, + AgentHookPayloadCommon, AgentHookPermissionMode, AgentHookPermissionOutcome, AgentHookScope, + AgentHookSettings, AgentHookSettingsLayer, MAX_HOOK_MODEL_OUTPUT_BYTES, +}; +use serde_json::json; +use std::path::Path; + +fn engine(hooks_json: &str) -> AgentHookEngine { + let (settings, issues) = AgentHookSettings::from_layers(&[AgentHookSettingsLayer { + scope: AgentHookScope::User, + source: "test hooks.json".to_string(), + bytes: hooks_json.as_bytes().to_vec(), + }]); + assert!(issues.is_empty(), "unexpected settings issues: {issues:?}"); + AgentHookEngine::new(settings) +} + +fn pre_tool_use_payload(tool_name: &str) -> AgentHookPayload { + AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: Some("turn-1".to_string()), + }, + event: AgentHookEventPayload::PreToolUse { + tool_name: tool_name.to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({"command": "ls"}), + }, + } +} + +fn session_start_payload() -> AgentHookPayload { + AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: None, + }, + event: AgentHookEventPayload::SessionStart { + source: "startup".to_string(), + }, + } +} + +async fn dispatch(engine: &AgentHookEngine, payload: &AgentHookPayload) -> AgentHookOutcome { + engine.dispatch(payload, Path::new(".")).await +} + +#[tokio::test] +async fn payload_is_delivered_on_stdin() { + // `cat` echoes the payload JSON, which parses as a decision document with + // no recognized fields, so nothing is blocked and no context is added. + let echo_engine = + engine(r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"cat"}]}]}}"#); + let outcome = dispatch(&echo_engine, &session_start_payload()).await; + + assert_eq!(outcome.executed_handlers, 1); + assert!(!outcome.is_blocked()); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + + // Now assert the payload content itself reached the process. + let field_engine = engine( + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"python3 -c \"import json,sys; d=json.load(sys.stdin); print(d['hook_event_name'], d['session_id'], d['cwd'], d['model'], d['permission_mode'], d['source'], 'turn_id' in d)\""}]}]}}"#, + ); + let outcome = dispatch(&field_engine, &session_start_payload()).await; + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + outcome.additional_context, + vec!["SessionStart session-1 / model-x default startup False".to_string()] + ); +} + +#[tokio::test] +async fn exit_code_zero_with_plain_stdout_becomes_context_for_context_events() { + let engine = engine( + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo remember this"}]}]}}"#, + ); + let outcome = dispatch(&engine, &session_start_payload()).await; + + assert_eq!( + outcome.additional_context, + vec!["remember this".to_string()] + ); + assert!(!outcome.is_blocked()); +} + +#[tokio::test] +async fn plain_stdout_is_ignored_for_non_context_events() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo chatter"}]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert!(outcome.additional_context.is_empty()); + assert!(!outcome.is_blocked()); + assert!(outcome.permission.is_none()); +} + +#[tokio::test] +async fn exit_code_two_blocks_with_stderr_as_the_reason() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo not allowed here >&2; exit 2"}]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert_eq!(outcome.block_reason.as_deref(), Some("not allowed here")); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); +} + +#[tokio::test] +async fn other_exit_codes_warn_without_blocking() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo broken >&2; exit 7"}]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert!(!outcome.is_blocked()); + assert_eq!(outcome.warnings.len(), 1); + assert!( + outcome.warnings[0].contains("non-blocking code 7"), + "{:?}", + outcome.warnings + ); +} + +#[tokio::test] +async fn permission_decision_deny_is_honored() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"blocked by policy\"}}'"}]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert_eq!( + outcome.permission, + Some(AgentHookPermissionOutcome::Deny { + reason: Some("blocked by policy".to_string()) + }) + ); + assert!(outcome.permission_denied()); +} + +#[tokio::test] +async fn permission_decision_allow_and_updated_input_are_honored() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"ls -la\"}}}'"}]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert_eq!( + outcome.permission, + Some(AgentHookPermissionOutcome::Allow { reason: None }) + ); + assert_eq!(outcome.updated_input, Some(json!({"command": "ls -la"}))); +} + +#[tokio::test] +async fn legacy_block_decision_is_honored() { + let engine = engine( + r#"{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"printf '{\"decision\":\"block\",\"reason\":\"fix the lint errors\"}'"}]}]}}"#, + ); + let payload = AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: Some("turn-1".to_string()), + }, + event: AgentHookEventPayload::PostToolUse { + tool_name: "Edit".to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({}), + tool_response: json!({}), + }, + }; + let outcome = dispatch(&engine, &payload).await; + + assert_eq!(outcome.block_reason.as_deref(), Some("fix the lint errors")); +} + +#[tokio::test] +async fn additional_context_and_system_message_are_collected() { + let engine = engine( + r#"{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"printf '{\"systemMessage\":\"ran the checker\",\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"2 files changed\"}}'"}]}]}}"#, + ); + let payload = AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: Some("turn-1".to_string()), + }, + event: AgentHookEventPayload::PostToolUse { + tool_name: "Edit".to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({}), + tool_response: json!({}), + }, + }; + let outcome = dispatch(&engine, &payload).await; + + assert_eq!( + outcome.additional_context, + vec!["2 files changed".to_string()] + ); + assert_eq!(outcome.system_messages, vec!["ran the checker".to_string()]); + assert!(!outcome.is_blocked()); +} + +#[tokio::test] +async fn continue_false_sets_a_stop_reason() { + let engine = engine( + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"printf '{\"continue\":false,\"stopReason\":\"budget exhausted\"}'"}]}]}}"#, + ); + let payload = AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: Some("turn-1".to_string()), + }, + event: AgentHookEventPayload::Stop { + stop_hook_active: false, + last_assistant_message: None, + }, + }; + let outcome = dispatch(&engine, &payload).await; + + assert_eq!(outcome.stop_reason.as_deref(), Some("budget exhausted")); +} + +#[tokio::test] +async fn permission_request_decision_behavior_is_honored() { + let engine = engine( + r#"{"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\",\"message\":\"trusted path\"}}}'"}]}]}}"#, + ); + let payload = AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: Some("turn-1".to_string()), + }, + event: AgentHookEventPayload::PermissionRequest { + tool_name: "Write".to_string(), + tool_input: json!({"file_path": "/tmp/x"}), + }, + }; + let outcome = dispatch(&engine, &payload).await; + + assert_eq!( + outcome.permission, + Some(AgentHookPermissionOutcome::Allow { + reason: Some("trusted path".to_string()) + }) + ); +} + +#[tokio::test] +async fn matchers_select_which_handlers_run() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[ + {"matcher":"Bash","hooks":[{"type":"command","command":"echo bash >&2; exit 2"}]}, + {"matcher":"Write","hooks":[{"type":"command","command":"echo write >&2; exit 2"}]} + ]}}"#, + ); + + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + assert_eq!(outcome.block_reason.as_deref(), Some("bash")); + assert_eq!(outcome.executed_handlers, 1); + + let outcome = dispatch(&engine, &pre_tool_use_payload("Read")).await; + assert_eq!(outcome.executed_handlers, 0); + assert!(!outcome.is_blocked()); +} + +#[tokio::test] +async fn first_blocking_handler_stops_later_handlers() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[ + {"type":"command","command":"echo first blocks >&2; exit 2"}, + {"type":"command","command":"echo second should not run >&2; exit 2"} + ]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert_eq!(outcome.block_reason.as_deref(), Some("first blocks")); + assert_eq!(outcome.executed_handlers, 1); +} + +#[tokio::test] +async fn handlers_run_in_configuration_order_and_outcomes_merge() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[ + {"type":"command","command":"printf '{\"systemMessage\":\"first\"}'"}, + {"type":"command","command":"printf '{\"systemMessage\":\"second\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"updatedInput\":{\"command\":\"safe\"}}}'"} + ]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert_eq!(outcome.executed_handlers, 2); + assert_eq!( + outcome.system_messages, + vec!["first".to_string(), "second".to_string()] + ); + assert_eq!(outcome.updated_input, Some(json!({"command": "safe"}))); +} + +#[tokio::test] +async fn a_deny_after_an_allow_wins() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[ + {"type":"command","command":"printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"}}'"}, + {"type":"command","command":"printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"second says no\"}}'"} + ]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert_eq!( + outcome.permission, + Some(AgentHookPermissionOutcome::Deny { + reason: Some("second says no".to_string()) + }) + ); +} + +#[tokio::test] +async fn timeouts_kill_the_handler_and_warn() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"sleep 30","timeout":1}]}]}}"#, + ); + let started = std::time::Instant::now(); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "timeout was not enforced" + ); + assert!(!outcome.is_blocked()); + assert_eq!(outcome.warnings.len(), 1); + assert!( + outcome.warnings[0].contains("timed out"), + "{:?}", + outcome.warnings + ); +} + +#[tokio::test] +async fn a_hook_that_never_reads_a_large_payload_still_times_out() { + // The payload must exceed the OS pipe buffer so the stdin write blocks + // until the handler drains it — which this handler never does. + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"sleep 30","timeout":1}]}]}}"#, + ); + let mut payload = pre_tool_use_payload("Bash"); + payload.event = AgentHookEventPayload::PreToolUse { + tool_name: "Bash".to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({ "command": "x".repeat(512 * 1024) }), + }; + + let started = std::time::Instant::now(); + let outcome = dispatch(&engine, &payload).await; + + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "dispatch hung on the stdin write instead of timing out" + ); + assert!(!outcome.is_blocked()); + assert_eq!(outcome.warnings.len(), 1); + assert!( + outcome.warnings[0].contains("timed out"), + "{:?}", + outcome.warnings + ); +} + +#[tokio::test] +async fn a_hook_that_echoes_a_large_payload_does_not_deadlock() { + // `cat` reads stdin and writes it straight back. With a payload larger + // than the pipe buffer in both directions, a sequential write-then-wait + // would deadlock: the parent blocks writing stdin while the child blocks + // writing stdout. The write and the wait must be driven concurrently. + let engine = engine( + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"cat","timeout":20}]}]}}"#, + ); + let mut payload = session_start_payload(); + payload.event = AgentHookEventPayload::SessionStart { + source: "x".repeat(512 * 1024), + }; + + let started = std::time::Instant::now(); + let outcome = dispatch(&engine, &payload).await; + + assert!( + started.elapsed() < std::time::Duration::from_secs(15), + "dispatch deadlocked between the stdin write and the child's stdout" + ); + assert_eq!(outcome.executed_handlers, 1); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); +} + +#[tokio::test] +async fn a_hook_that_exits_without_reading_stdin_still_succeeds() { + // The write fails with EPIPE; that must not turn into a warning or block. + let engine = engine( + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"exec echo done"}]}]}}"#, + ); + let outcome = dispatch(&engine, &session_start_payload()).await; + + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(outcome.additional_context, vec!["done".to_string()]); +} + +#[tokio::test] +async fn model_visible_text_from_json_output_is_capped() { + // The budget must apply to JSON decision fields, not just plain stdout. + let engine = engine( + r#"{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"python3 -c \"import json;print(json.dumps({'hookSpecificOutput':{'hookEventName':'PostToolUse','additionalContext':'x'*50000}}))\""}]}]}}"#, + ); + let payload = AgentHookPayload { + common: AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: Some("turn-1".to_string()), + }, + event: AgentHookEventPayload::PostToolUse { + tool_name: "Edit".to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({}), + tool_response: json!({}), + }, + }; + let outcome = dispatch(&engine, &payload).await; + + assert_eq!(outcome.additional_context.len(), 1); + let context = &outcome.additional_context[0]; + assert!( + context.len() <= MAX_HOOK_MODEL_OUTPUT_BYTES + 32, + "context was not capped: {} bytes", + context.len() + ); + assert!( + context.ends_with("[hook output truncated]"), + "{context:.80}" + ); +} + +#[tokio::test] +async fn missing_command_warns_without_blocking() { + let engine = engine( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"definitely-not-an-installed-binary-xyz"}]}]}}"#, + ); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + // `sh -c` reports a missing binary as exit code 127, a non-blocking error. + assert!(!outcome.is_blocked()); + assert_eq!(outcome.warnings.len(), 1); +} + +#[tokio::test] +async fn events_without_configured_rules_execute_nothing() { + let engine = + engine(r#"{"hooks":{"SessionEnd":[{"hooks":[{"type":"command","command":"echo x"}]}]}}"#); + let outcome = dispatch(&engine, &pre_tool_use_payload("Bash")).await; + + assert_eq!(outcome.executed_handlers, 0); + assert!(outcome.additional_context.is_empty()); +} diff --git a/src/crates/execution/agent-runtime/tests/native_hook_payload_contracts.rs b/src/crates/execution/agent-runtime/tests/native_hook_payload_contracts.rs new file mode 100644 index 0000000000..a76e64526d --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/native_hook_payload_contracts.rs @@ -0,0 +1,349 @@ +//! Native agent hook stdin payload contracts. +//! +//! Field names and per-event fields must stay identical to the Codex hook +//! process interface so existing hook scripts keep working. + +use bitfun_agent_runtime::native_hooks::{ + AgentHookEvent, AgentHookEventPayload, AgentHookPayload, AgentHookPayloadCommon, + AgentHookPermissionMode, +}; +use serde_json::json; + +fn common() -> AgentHookPayloadCommon { + AgentHookPayloadCommon { + session_id: "session-1".to_string(), + transcript_path: None, + cwd: "/workspace".to_string(), + model: "model-x".to_string(), + permission_mode: AgentHookPermissionMode::Default, + turn_id: Some("turn-1".to_string()), + } +} + +fn payload(event: AgentHookEventPayload) -> serde_json::Value { + AgentHookPayload { + common: common(), + event, + } + .to_json() +} + +#[test] +fn common_fields_are_present_for_every_event() { + let events = [ + AgentHookEventPayload::SessionStart { + source: "startup".to_string(), + }, + AgentHookEventPayload::SessionEnd { + reason: "other".to_string(), + }, + AgentHookEventPayload::UserPromptSubmit { + prompt: "hi".to_string(), + }, + AgentHookEventPayload::PreToolUse { + tool_name: "Bash".to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({"command": "ls"}), + }, + AgentHookEventPayload::Stop { + stop_hook_active: false, + last_assistant_message: None, + }, + ]; + + for event in events { + let expected_event_name = event.event().as_str().to_string(); + let value = payload(event); + assert_eq!(value["session_id"], json!("session-1")); + assert_eq!(value["transcript_path"], json!(null)); + assert_eq!(value["cwd"], json!("/workspace")); + assert_eq!(value["hook_event_name"], json!(expected_event_name)); + assert_eq!(value["model"], json!("model-x")); + assert_eq!(value["permission_mode"], json!("default")); + } +} + +#[test] +fn turn_id_is_present_only_for_turn_scoped_events() { + let session_start = payload(AgentHookEventPayload::SessionStart { + source: "resume".to_string(), + }); + assert!(session_start.get("turn_id").is_none()); + + let session_end = payload(AgentHookEventPayload::SessionEnd { + reason: "other".to_string(), + }); + assert!(session_end.get("turn_id").is_none()); + + let pre_tool_use = payload(AgentHookEventPayload::PreToolUse { + tool_name: "Bash".to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({}), + }); + assert_eq!(pre_tool_use["turn_id"], json!("turn-1")); +} + +#[test] +fn permission_mode_vocabulary_matches_codex() { + let modes = [ + (AgentHookPermissionMode::Default, "default"), + (AgentHookPermissionMode::AcceptEdits, "acceptEdits"), + (AgentHookPermissionMode::Plan, "plan"), + (AgentHookPermissionMode::DontAsk, "dontAsk"), + ( + AgentHookPermissionMode::BypassPermissions, + "bypassPermissions", + ), + ]; + for (mode, expected) in modes { + let value = AgentHookPayload { + common: AgentHookPayloadCommon { + permission_mode: mode, + ..common() + }, + event: AgentHookEventPayload::Stop { + stop_hook_active: false, + last_assistant_message: None, + }, + } + .to_json(); + assert_eq!(value["permission_mode"], json!(expected)); + } +} + +#[test] +fn event_specific_fields_use_codex_names() { + let session_start = payload(AgentHookEventPayload::SessionStart { + source: "compact".to_string(), + }); + assert_eq!(session_start["source"], json!("compact")); + + let session_end = payload(AgentHookEventPayload::SessionEnd { + reason: "other".to_string(), + }); + assert_eq!(session_end["reason"], json!("other")); + + let prompt = payload(AgentHookEventPayload::UserPromptSubmit { + prompt: "do the thing".to_string(), + }); + assert_eq!(prompt["prompt"], json!("do the thing")); + + let pre_tool_use = payload(AgentHookEventPayload::PreToolUse { + tool_name: "Bash".to_string(), + tool_use_id: "call-1".to_string(), + tool_input: json!({"command": "ls -la"}), + }); + assert_eq!(pre_tool_use["tool_name"], json!("Bash")); + assert_eq!(pre_tool_use["tool_use_id"], json!("call-1")); + assert_eq!(pre_tool_use["tool_input"], json!({"command": "ls -la"})); + + let permission_request = payload(AgentHookEventPayload::PermissionRequest { + tool_name: "Write".to_string(), + tool_input: json!({"file_path": "/tmp/x"}), + }); + assert_eq!(permission_request["tool_name"], json!("Write")); + assert_eq!( + permission_request["tool_input"], + json!({"file_path": "/tmp/x"}) + ); + // PermissionRequest carries no tool_use_id in the Codex contract. + assert!(permission_request.get("tool_use_id").is_none()); + + let post_tool_use = payload(AgentHookEventPayload::PostToolUse { + tool_name: "Read".to_string(), + tool_use_id: "call-2".to_string(), + tool_input: json!({"file_path": "/tmp/x"}), + tool_response: json!({"result": "contents", "is_error": false}), + }); + assert_eq!(post_tool_use["tool_name"], json!("Read")); + assert_eq!(post_tool_use["tool_use_id"], json!("call-2")); + assert_eq!( + post_tool_use["tool_response"], + json!({"result": "contents", "is_error": false}) + ); + + for event in [ + AgentHookEventPayload::PreCompact { + trigger: "auto".to_string(), + }, + AgentHookEventPayload::PostCompact { + trigger: "manual".to_string(), + }, + ] { + let expected = match &event { + AgentHookEventPayload::PreCompact { trigger } + | AgentHookEventPayload::PostCompact { trigger } => trigger.clone(), + _ => unreachable!(), + }; + let value = payload(event); + assert_eq!(value["trigger"], json!(expected)); + } + + let subagent_start = payload(AgentHookEventPayload::SubagentStart { + agent_id: "agent-1".to_string(), + agent_type: "reviewer".to_string(), + }); + assert_eq!(subagent_start["agent_id"], json!("agent-1")); + assert_eq!(subagent_start["agent_type"], json!("reviewer")); + + let subagent_stop = payload(AgentHookEventPayload::SubagentStop { + agent_id: "agent-1".to_string(), + agent_type: "reviewer".to_string(), + agent_transcript_path: None, + stop_hook_active: true, + last_assistant_message: Some("done".to_string()), + }); + assert_eq!(subagent_stop["agent_transcript_path"], json!(null)); + assert_eq!(subagent_stop["stop_hook_active"], json!(true)); + assert_eq!(subagent_stop["last_assistant_message"], json!("done")); + + let stop = payload(AgentHookEventPayload::Stop { + stop_hook_active: true, + last_assistant_message: Some("final".to_string()), + }); + assert_eq!(stop["stop_hook_active"], json!(true)); + assert_eq!(stop["last_assistant_message"], json!("final")); +} + +#[test] +fn optional_last_assistant_message_is_omitted_when_absent() { + let stop = payload(AgentHookEventPayload::Stop { + stop_hook_active: false, + last_assistant_message: None, + }); + assert!(stop.get("last_assistant_message").is_none()); +} + +#[test] +fn transcript_path_is_serialized_when_available() { + let value = AgentHookPayload { + common: AgentHookPayloadCommon { + transcript_path: Some("/tmp/transcript.jsonl".to_string()), + ..common() + }, + event: AgentHookEventPayload::SessionStart { + source: "startup".to_string(), + }, + } + .to_json(); + assert_eq!(value["transcript_path"], json!("/tmp/transcript.jsonl")); +} + +#[test] +fn matcher_context_matches_the_documented_events() { + let cases: Vec<(AgentHookEventPayload, Option<&str>)> = vec![ + ( + AgentHookEventPayload::PreToolUse { + tool_name: "Bash".to_string(), + tool_use_id: "c".to_string(), + tool_input: json!({}), + }, + Some("Bash"), + ), + ( + AgentHookEventPayload::PermissionRequest { + tool_name: "Write".to_string(), + tool_input: json!({}), + }, + Some("Write"), + ), + ( + AgentHookEventPayload::PostToolUse { + tool_name: "Read".to_string(), + tool_use_id: "c".to_string(), + tool_input: json!({}), + tool_response: json!({}), + }, + Some("Read"), + ), + ( + AgentHookEventPayload::PreCompact { + trigger: "auto".to_string(), + }, + Some("auto"), + ), + ( + AgentHookEventPayload::PostCompact { + trigger: "manual".to_string(), + }, + Some("manual"), + ), + ( + AgentHookEventPayload::SessionStart { + source: "resume".to_string(), + }, + Some("resume"), + ), + ( + AgentHookEventPayload::SubagentStart { + agent_id: "a".to_string(), + agent_type: "reviewer".to_string(), + }, + Some("reviewer"), + ), + ( + AgentHookEventPayload::SubagentStop { + agent_id: "a".to_string(), + agent_type: "reviewer".to_string(), + agent_transcript_path: None, + stop_hook_active: false, + last_assistant_message: None, + }, + Some("reviewer"), + ), + // No matcher filtering for these events. + ( + AgentHookEventPayload::UserPromptSubmit { + prompt: "p".to_string(), + }, + None, + ), + ( + AgentHookEventPayload::Stop { + stop_hook_active: false, + last_assistant_message: None, + }, + None, + ), + ( + AgentHookEventPayload::SessionEnd { + reason: "other".to_string(), + }, + None, + ), + ]; + + for (event, expected) in cases { + let event_name = event.event(); + assert_eq!( + event.matcher_value(), + expected, + "{event_name} matcher context mismatch" + ); + } +} + +#[test] +fn event_names_render_exactly_as_configured() { + assert_eq!(AgentHookEvent::PreToolUse.as_str(), "PreToolUse"); + assert_eq!( + AgentHookEvent::PermissionRequest.as_str(), + "PermissionRequest" + ); + assert_eq!(AgentHookEvent::PostToolUse.as_str(), "PostToolUse"); + assert_eq!(AgentHookEvent::PreCompact.as_str(), "PreCompact"); + assert_eq!(AgentHookEvent::PostCompact.as_str(), "PostCompact"); + assert_eq!(AgentHookEvent::SessionStart.as_str(), "SessionStart"); + assert_eq!(AgentHookEvent::SessionEnd.as_str(), "SessionEnd"); + assert_eq!( + AgentHookEvent::UserPromptSubmit.as_str(), + "UserPromptSubmit" + ); + assert_eq!(AgentHookEvent::SubagentStart.as_str(), "SubagentStart"); + assert_eq!(AgentHookEvent::SubagentStop.as_str(), "SubagentStop"); + assert_eq!(AgentHookEvent::Stop.as_str(), "Stop"); + for event in AgentHookEvent::ALL { + assert_eq!(AgentHookEvent::parse(event.as_str()), Some(event)); + } + assert_eq!(AgentHookEvent::parse("NotAnEvent"), None); +} diff --git a/src/crates/execution/agent-runtime/tests/native_hook_settings_contracts.rs b/src/crates/execution/agent-runtime/tests/native_hook_settings_contracts.rs new file mode 100644 index 0000000000..3346160854 --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/native_hook_settings_contracts.rs @@ -0,0 +1,385 @@ +//! Native agent hook settings parsing contracts. +//! +//! These assertions pin the Codex-compatible configuration surface: document +//! shape, the fixed event list, matcher semantics, handler fields, timeout +//! defaults, and the layer/limit rules. + +use bitfun_agent_runtime::native_hooks::{ + AgentHookEvent, AgentHookScope, AgentHookSettings, AgentHookSettingsIssue, + AgentHookSettingsLayer, MAX_HOOK_HANDLERS, +}; + +fn layer(scope: AgentHookScope, source: &str, json: &str) -> AgentHookSettingsLayer { + AgentHookSettingsLayer { + scope, + source: source.to_string(), + bytes: json.as_bytes().to_vec(), + } +} + +fn user_layer(json: &str) -> AgentHookSettingsLayer { + layer(AgentHookScope::User, "user hooks.json", json) +} + +#[test] +fn parses_codex_document_shape_with_matcher_and_command_handler() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{ + "description": "example", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 check.py", + "timeout": 30, + "statusMessage": "Checking command" + } + ] + } + ] + } + }"#, + )]); + + assert!(issues.is_empty(), "unexpected issues: {issues:?}"); + let rules = settings.rules_for(AgentHookEvent::PreToolUse); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].handlers.len(), 1); + assert_eq!(rules[0].handlers[0].command, "python3 check.py"); + assert_eq!(rules[0].handlers[0].timeout_seconds, Some(30)); + assert_eq!( + rules[0].handlers[0].status_message.as_deref(), + Some("Checking command") + ); + assert_eq!(rules[0].scope, AgentHookScope::User); + assert_eq!(settings.total_handlers(), 1); +} + +#[test] +fn supports_every_codex_event_name() { + for event in AgentHookEvent::ALL { + let json = format!( + r#"{{"hooks":{{"{}":[{{"hooks":[{{"type":"command","command":"true"}}]}}]}}}}"#, + event.as_str() + ); + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer(&json)]); + assert!(issues.is_empty(), "{event} produced issues: {issues:?}"); + assert!(settings.has_rules(event), "{event} rule was not registered"); + } +} + +#[test] +fn unknown_event_names_are_dropped_but_valid_events_survive() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{ + "hooks": { + "PreToolUes": [{"hooks":[{"type":"command","command":"typo"}]}], + "PreToolUse": [{"hooks":[{"type":"command","command":"kept"}]}] + } + }"#, + )]); + + assert!(issues.iter().any(|issue| matches!( + issue, + AgentHookSettingsIssue::EventNameUnsupported { event, .. } if event == "PreToolUes" + ))); + let rules = settings.rules_for(AgentHookEvent::PreToolUse); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].handlers[0].command, "kept"); +} + +#[test] +fn unexpected_root_keys_reject_the_whole_document() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x"}]}]},"unexpected":true}"#, + )]); + + assert!(settings.is_empty()); + assert!(issues + .iter() + .any(|issue| matches!(issue, AgentHookSettingsIssue::DocumentInvalid { .. }))); +} + +#[test] +fn reserved_state_table_is_not_treated_as_an_event() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{"hooks":{"state":{"anything":{"enabled":false}}}}"#, + )]); + + assert!(settings.is_empty()); + assert!(issues.is_empty(), "unexpected issues: {issues:?}"); +} + +#[test] +fn matcher_semantics_follow_codex_rules() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{ + "hooks": { + "PreToolUse": [ + {"hooks":[{"type":"command","command":"absent"}]}, + {"matcher":"","hooks":[{"type":"command","command":"empty"}]}, + {"matcher":"*","hooks":[{"type":"command","command":"star"}]}, + {"matcher":"Bash","hooks":[{"type":"command","command":"exact"}]}, + {"matcher":"^Bash$","hooks":[{"type":"command","command":"anchored"}]}, + {"matcher":"Edit|Write","hooks":[{"type":"command","command":"alternation"}]}, + {"matcher":"mcp__filesystem__.*","hooks":[{"type":"command","command":"wildcard"}]} + ] + } + }"#, + )]); + assert!(issues.is_empty(), "unexpected issues: {issues:?}"); + let rules = settings.rules_for(AgentHookEvent::PreToolUse); + assert_eq!(rules.len(), 7); + + // Absent, empty, and "*" match everything. + for rule in &rules[..3] { + assert!(rule.matcher.matches(Some("AnyTool"))); + assert!(rule.matcher.matches(None)); + } + // "Bash" is an exact whole-value match, not a substring match. + assert!(rules[3].matcher.matches(Some("Bash"))); + assert!(!rules[3].matcher.matches(Some("BashOutput"))); + // Anchored regex behaves the same. + assert!(rules[4].matcher.matches(Some("Bash"))); + assert!(!rules[4].matcher.matches(Some("Bashful"))); + // Alternation matches either branch and nothing else. + assert!(rules[5].matcher.matches(Some("Edit"))); + assert!(rules[5].matcher.matches(Some("Write"))); + assert!(!rules[5].matcher.matches(Some("Read"))); + // Regex wildcards match MCP tool families by prefix. + assert!(rules[6].matcher.matches(Some("mcp__filesystem__read_file"))); + assert!(!rules[6].matcher.matches(Some("mcp__github__search"))); +} + +#[test] +fn malformed_matchers_never_match_everything() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{"hooks":{"PreToolUse":[{"matcher":{"tool":"Bash"},"hooks":[{"type":"command","command":"x"}]}]}}"#, + )]); + + assert!(issues + .iter() + .any(|issue| matches!(issue, AgentHookSettingsIssue::MatcherInvalid { .. }))); + let rules = settings.rules_for(AgentHookEvent::PreToolUse); + assert_eq!(rules.len(), 1); + assert!(!rules[0].matcher.matches(Some("Bash"))); + assert!(!rules[0].matcher.matches(None)); +} + +#[test] +fn unparsable_regex_matcher_is_reported_and_never_matches() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash(","hooks":[{"type":"command","command":"x"}]}]}}"#, + )]); + + assert!(issues + .iter() + .any(|issue| matches!(issue, AgentHookSettingsIssue::MatcherInvalid { .. }))); + assert!(!settings.rules_for(AgentHookEvent::PreToolUse)[0] + .matcher + .matches(Some("Bash("))); +} + +#[test] +fn prompt_and_agent_handlers_are_recognized_but_not_executable() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{ + "hooks": { + "SessionStart": [ + {"hooks":[ + {"type":"prompt","prompt":"remind me"}, + {"type":"agent","prompt":"delegate"}, + {"type":"command","command":"echo ok"} + ]} + ] + } + }"#, + )]); + + assert_eq!( + issues + .iter() + .filter(|issue| matches!(issue, AgentHookSettingsIssue::HandlerUnsupported { .. })) + .count(), + 2 + ); + let rules = settings.rules_for(AgentHookEvent::SessionStart); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].handlers.len(), 1); + assert_eq!(rules[0].handlers[0].command, "echo ok"); +} + +#[test] +fn invalid_handlers_are_dropped_without_losing_valid_siblings() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{ + "hooks": { + "PostToolUse": [ + {"hooks":[ + {"type":"http","url":"https://example.test"}, + {"type":"command"}, + {"type":"command","command":" "}, + {"type":"command","command":"echo ok","timeout":0}, + {"type":"command","command":"kept"} + ]} + ] + } + }"#, + )]); + + assert_eq!( + issues + .iter() + .filter(|issue| matches!(issue, AgentHookSettingsIssue::HandlerInvalid { .. })) + .count(), + 4 + ); + let rules = settings.rules_for(AgentHookEvent::PostToolUse); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].handlers.len(), 1); + assert_eq!(rules[0].handlers[0].command, "kept"); +} + +#[test] +fn malformed_event_and_group_shapes_are_reported() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer( + r#"{ + "hooks": { + "PreToolUse": {"not":"an array"}, + "PostToolUse": ["not an object", {"missing":"hooks array"}] + } + }"#, + )]); + + assert!(settings.is_empty()); + assert!(issues + .iter() + .any(|issue| matches!(issue, AgentHookSettingsIssue::EventInvalid { .. }))); + assert_eq!( + issues + .iter() + .filter(|issue| matches!(issue, AgentHookSettingsIssue::GroupInvalid { .. })) + .count(), + 2 + ); +} + +#[test] +fn timeout_defaults_and_caps_follow_codex() { + let (settings, _) = AgentHookSettings::from_layers(&[user_layer( + r#"{ + "hooks": { + "PreToolUse": [{"hooks":[{"type":"command","command":"a"}]}], + "SessionEnd": [{"hooks":[ + {"type":"command","command":"b"}, + {"type":"command","command":"c","timeout":30} + ]}] + } + }"#, + )]); + + let pre = &settings.rules_for(AgentHookEvent::PreToolUse)[0].handlers[0]; + assert_eq!( + pre.effective_timeout(AgentHookEvent::PreToolUse).as_secs(), + 600 + ); + + let session_end = &settings.rules_for(AgentHookEvent::SessionEnd)[0].handlers; + assert_eq!( + session_end[0] + .effective_timeout(AgentHookEvent::SessionEnd) + .as_secs(), + 1 + ); + // A configured SessionEnd timeout is capped so session teardown cannot hang. + assert_eq!( + session_end[1] + .effective_timeout(AgentHookEvent::SessionEnd) + .as_secs(), + 3 + ); +} + +#[test] +fn user_layers_are_ordered_before_project_layers() { + let (settings, issues) = AgentHookSettings::from_layers(&[ + user_layer(r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"user"}]}]}}"#), + layer( + AgentHookScope::Project, + "project hooks.json", + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"project"}]}]}}"#, + ), + ]); + + assert!(issues.is_empty(), "unexpected issues: {issues:?}"); + let rules = settings.rules_for(AgentHookEvent::Stop); + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].scope, AgentHookScope::User); + assert_eq!(rules[0].handlers[0].command, "user"); + assert_eq!(rules[1].scope, AgentHookScope::Project); + assert_eq!(rules[1].handlers[0].command, "project"); +} + +#[test] +fn handler_limit_is_enforced_across_layers() { + let handlers = (0..MAX_HOOK_HANDLERS + 10) + .map(|index| format!(r#"{{"type":"command","command":"echo {index}"}}"#)) + .collect::>() + .join(","); + let json = format!(r#"{{"hooks":{{"Stop":[{{"hooks":[{handlers}]}}]}}}}"#); + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer(&json)]); + + assert_eq!(settings.total_handlers(), MAX_HOOK_HANDLERS); + assert!(issues + .iter() + .any(|issue| matches!(issue, AgentHookSettingsIssue::HandlerLimitExceeded { .. }))); +} + +#[test] +fn non_json_documents_are_reported_as_invalid() { + let (settings, issues) = AgentHookSettings::from_layers(&[user_layer("not json at all")]); + + assert!(settings.is_empty()); + assert!(issues + .iter() + .any(|issue| matches!(issue, AgentHookSettingsIssue::DocumentInvalid { .. }))); +} + +#[test] +fn missing_hooks_key_is_accepted_without_issues() { + let (settings, issues) = + AgentHookSettings::from_layers(&[user_layer(r#"{"description":"nothing configured"}"#)]); + + assert!(settings.is_empty()); + assert!(issues.is_empty(), "unexpected issues: {issues:?}"); +} + +#[test] +fn turn_scope_and_context_flags_match_the_documented_events() { + assert!(!AgentHookEvent::SessionStart.is_turn_scoped()); + assert!(!AgentHookEvent::SessionEnd.is_turn_scoped()); + for event in AgentHookEvent::ALL { + if !matches!( + event, + AgentHookEvent::SessionStart | AgentHookEvent::SessionEnd + ) { + assert!(event.is_turn_scoped(), "{event} should carry turn_id"); + } + } + + let context_events = AgentHookEvent::ALL + .into_iter() + .filter(|event| event.plain_stdout_is_context()) + .collect::>(); + assert_eq!( + context_events, + vec![ + AgentHookEvent::SessionStart, + AgentHookEvent::UserPromptSubmit, + AgentHookEvent::SubagentStart, + ] + ); +} diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index acecaab503..dfb1fc71fb 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -23,6 +23,7 @@ import { BasicsConfig, EditorConfig, ExternalSourcesConfig, + HooksConfig, KeyboardShortcutsTab, McpToolsConfig, MemoriesConfig, @@ -62,6 +63,7 @@ function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { case 'memories': return MemoriesConfig; case 'mcp-tools': return McpToolsConfig; case 'external-sources': return ExternalSourcesConfig; + case 'hooks': return HooksConfig; case 'acp-agents': return AcpAgentsConfig; case 'editor': return EditorConfig; case 'keyboard': return KeyboardShortcutsTab; diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index 134d097f36..dea5145ee5 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -18,6 +18,7 @@ export type ConfigTab = | 'memories' | 'mcp-tools' | 'external-sources' + | 'hooks' | 'acp-agents' // | 'lsp' // temporarily hidden from config center | 'editor' @@ -237,6 +238,21 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'compatibility', ], }, + { + id: 'hooks', + labelKey: 'configCenter.tabs.hooks', + descriptionKey: 'configCenter.tabDescriptions.hooks', + keywords: [ + 'hooks', + 'hook', + 'lifecycle', + 'pretooluse', + 'posttooluse', + 'codex', + 'automation', + 'guardrail', + ], + }, { id: 'mcp-tools', labelKey: 'configCenter.tabs.mcpTools', diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts index f081906c49..19348b6700 100644 --- a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -5,6 +5,7 @@ const loadAIModelConfig = () => import('../../../infrastructure/config/component const loadMcpToolsConfig = () => import('../../../infrastructure/config/components/McpToolsConfig'); const loadAcpAgentsConfig = () => import('../../../infrastructure/config/components/AcpAgentsConfig'); const loadExternalSourcesConfig = () => import('../../../infrastructure/config/components/ExternalSourcesConfig'); +const loadHooksConfig = () => import('../../../infrastructure/config/components/HooksConfig'); const loadEditorConfig = () => import('../../../infrastructure/config/components/EditorConfig'); const loadBasicsConfig = () => import('../../../infrastructure/config/components/BasicsConfig'); const loadAppearanceConfig = () => import('../../../infrastructure/config/components/AppearanceConfig'); @@ -20,6 +21,7 @@ export const AIModelConfig = lazy(loadAIModelConfig); export const McpToolsConfig = lazy(loadMcpToolsConfig); export const AcpAgentsConfig = lazy(loadAcpAgentsConfig); export const ExternalSourcesConfig = lazy(loadExternalSourcesConfig); +export const HooksConfig = lazy(loadHooksConfig); export const EditorConfig = lazy(loadEditorConfig); export const BasicsConfig = lazy(loadBasicsConfig); export const AppearanceConfig = lazy(loadAppearanceConfig); @@ -53,6 +55,7 @@ const SETTINGS_CONTENT_LOADERS: Partial Promise memories: loadMemoriesConfig, 'mcp-tools': loadMcpToolsConfig, 'external-sources': loadExternalSourcesConfig, + hooks: loadHooksConfig, 'acp-agents': loadAcpAgentsConfig, editor: loadEditorConfig, keyboard: loadKeyboardShortcutsTab, diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index ac149761f2..fbddf85cc9 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -137,6 +137,17 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record | null | undefined +): AgentHooksConfigShape { + return { + ...DEFAULT_HOOKS_CONFIG, + ...(config ?? {}), + }; +} + +const HooksConfig: React.FC = () => { + const { t } = useTranslation('settings/hooks'); + const { error: notifyError, success: notifySuccess } = useNotification(); + + const [loading, setLoading] = useState(true); + const [config, setConfig] = useState(DEFAULT_HOOKS_CONFIG); + const [savingKey, setSavingKey] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const loaded = await configManager.getConfig>('app.hooks'); + setConfig(normalizeHooksConfig(loaded)); + } catch (error) { + log.error('Failed to load hooks config', error); + notifyError(error instanceof Error ? error.message : t('messages.loadFailed')); + } finally { + setLoading(false); + } + }, [notifyError, t]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const updateConfig = useCallback( + async (key: K, value: AgentHooksConfigShape[K]) => { + const previous = config; + const next = { ...config, [key]: value }; + setSavingKey(key); + setConfig(next); + try { + await configManager.setConfig('app.hooks', next); + notifySuccess(t('messages.saved')); + } catch (error) { + log.error('Failed to save hooks config', { key, error }); + setConfig(previous); + notifyError(error instanceof Error ? error.message : t('messages.saveFailed')); + } finally { + setSavingKey(null); + } + }, + [config, notifyError, notifySuccess, t] + ); + + const openCodexHooksDoc = useCallback(() => { + void systemAPI.openExternal(CODEX_HOOKS_DOC_URL).catch((error: unknown) => { + log.error('Failed to open the Codex hooks documentation', error); + }); + }, []); + + if (loading) { + return ( + + + + + + + ); + } + + return ( + + + + + + + void updateConfig('enabled', event.target.checked)} + disabled={savingKey !== null} + /> + + + + void updateConfig('project_hooks_enabled', event.target.checked)} + disabled={savingKey !== null || !config.enabled} + /> + + + + + + {null} + + + + {null} + + + + + + + + + + + ); +}; + +export default HooksConfig; diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index c053dfbe0f..ac5dc68710 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -31,6 +31,7 @@ export const ALL_NAMESPACES = [ 'settings/default-model', 'settings/editor', 'settings/external-sources', + 'settings/hooks', 'settings/lsp', 'settings/mcp', 'settings/mcp-tools', diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 79995938f0..38b460f85f 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -26,6 +26,7 @@ "memories": "Automatic memory generation, injection, retention windows, and memory models.", "mcpTools": "MCP servers and tool integrations.", "externalSources": "Load compatible commands and extensions from other AI applications.", + "hooks": "Run your own commands at Agent lifecycle points. Codex-compatible.", "acpAgents": "External ACP agents such as opencode, Claude Code, and Codex.", "editor": "Editor font, display, and formatting.", "lsp": "Language servers and code intelligence.", @@ -50,6 +51,7 @@ "skills": "Skills", "mcpTools": "MCP", "externalSources": "External AI Apps", + "hooks": "Agent Hooks", "acpAgents": "ACP Agents", "agents": "Agents", "editor": "Editor", diff --git a/src/web-ui/src/locales/en-US/settings/hooks.json b/src/web-ui/src/locales/en-US/settings/hooks.json new file mode 100644 index 0000000000..65c887fc50 --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/hooks.json @@ -0,0 +1,45 @@ +{ + "title": "Agent Hooks", + "subtitle": "Run your own commands at fixed points in the Agent's lifecycle — before and after tool calls, around permission prompts, and at session and turn boundaries.", + "loading": "Loading hook settings...", + "activation": { + "title": "Activation", + "description": "A hook runs with your user account's full privileges every time its event fires. Review any hook you did not write before enabling it." + }, + "fields": { + "enabled": { + "label": "Enable hooks", + "description": "Master switch. When off, no hook runs for any event." + }, + "projectHooks": { + "label": "Allow project hooks", + "description": "Honor the hook file inside the workspace. It executes commands from the checked-out repository, so enable this only for repositories you trust." + } + }, + "locations": { + "title": "Where hooks are declared", + "description": "Hooks live in hooks.json files, not in this settings document. Both layers are additive, with user hooks running first. Changes are picked up without restarting BitFun.", + "userFile": { + "label": "User hooks", + "description": "config/hooks.json in your BitFun user config directory (~/.config/bitfun on Linux, ~/Library/Application Support/bitfun on macOS, %APPDATA%\\bitfun on Windows)." + }, + "projectFile": { + "label": "Project hooks", + "description": ".bitfun/config/hooks.json inside the workspace. Only read when \"Allow project hooks\" is on." + } + }, + "compatibility": { + "title": "Codex compatibility", + "description": "The hooks.json document, the event names, the JSON payload on stdin, the exit-code meanings, and the JSON decision schema on stdout match Codex hooks, so a Codex hook script runs here unchanged.", + "reference": { + "label": "Event and payload reference", + "description": "Codex's hooks documentation is the reference for event names, payload fields, and the decision schema.", + "open": "Open Codex hooks docs" + } + }, + "messages": { + "saved": "Hook settings saved", + "saveFailed": "Failed to save hook settings", + "loadFailed": "Failed to load hook settings" + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 7d3d0895eb..5c6b321d61 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -47,6 +47,7 @@ "memories": "自动记忆生成、注入、整理窗口与记忆模型。", "mcpTools": "MCP 服务器与工具集成。", "externalSources": "加载其他 AI 应用中兼容的命令与扩展。", + "hooks": "在 Agent 生命周期节点运行你自己的命令,与 Codex Hooks 兼容。", "acpAgents": "opencode、Claude Code、Codex 等外部 ACP Agent。", "editor": "编辑器字体、显示与格式化。", "lsp": "语言服务与代码智能。", @@ -71,6 +72,7 @@ "skills": "技能", "mcpTools": "MCP", "externalSources": "外部 AI 应用", + "hooks": "Agent Hooks", "acpAgents": "ACP Agent", "agents": "智能体", "editor": "编辑器", diff --git a/src/web-ui/src/locales/zh-CN/settings/hooks.json b/src/web-ui/src/locales/zh-CN/settings/hooks.json new file mode 100644 index 0000000000..308b83e1f6 --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/hooks.json @@ -0,0 +1,45 @@ +{ + "title": "Agent Hooks", + "subtitle": "在 Agent 生命周期的固定节点运行你自己的命令 —— 工具调用前后、权限确认前后,以及会话与回合的边界。", + "loading": "正在加载 Hook 设置...", + "activation": { + "title": "启用", + "description": "每次对应事件触发时,Hook 都会以你的用户权限运行。启用任何非你本人编写的 Hook 之前,请先审阅它。" + }, + "fields": { + "enabled": { + "label": "启用 Hooks", + "description": "总开关。关闭后,任何事件都不会运行 Hook。" + }, + "projectHooks": { + "label": "允许项目级 Hooks", + "description": "读取工作区内的 Hook 文件。它执行的是仓库中的命令,请只对你信任的仓库开启。" + } + }, + "locations": { + "title": "Hook 声明位置", + "description": "Hook 声明在 hooks.json 文件中,不在本设置文档里。两个层级是叠加关系,用户层优先执行。修改后无需重启 BitFun。", + "userFile": { + "label": "用户 Hooks", + "description": "BitFun 用户配置目录下的 config/hooks.json(Linux 为 ~/.config/bitfun,macOS 为 ~/Library/Application Support/bitfun,Windows 为 %APPDATA%\\bitfun)。" + }, + "projectFile": { + "label": "项目 Hooks", + "description": "工作区内的 .bitfun/config/hooks.json。仅在开启“允许项目级 Hooks”时读取。" + } + }, + "compatibility": { + "title": "Codex 兼容", + "description": "hooks.json 文档结构、事件名、stdin 上的 JSON 载荷、退出码语义以及 stdout 上的 JSON 决策结构均与 Codex Hooks 一致,因此 Codex 的 Hook 脚本可以直接在这里运行。", + "reference": { + "label": "事件与载荷参考", + "description": "事件名、载荷字段和决策结构以 Codex 的 Hooks 文档为准。", + "open": "打开 Codex Hooks 文档" + } + }, + "messages": { + "saved": "Hook 设置已保存", + "saveFailed": "保存 Hook 设置失败", + "loadFailed": "加载 Hook 设置失败" + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 6fba500b7d..1f0be13c5a 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -45,6 +45,7 @@ "memories": "自動記憶生成、注入、整理窗口與記憶模型。", "mcpTools": "MCP 伺服器與工具集成。", "externalSources": "載入其他 AI 應用中相容的命令與擴充。", + "hooks": "在 Agent 生命週期節點執行你自己的命令,與 Codex Hooks 相容。", "acpAgents": "opencode、Claude Code、Codex 等外部 ACP Agent。", "editor": "編輯器字體、顯示與格式化。", "lsp": "語言服務與代碼智能。", @@ -69,6 +70,7 @@ "skills": "技能", "mcpTools": "MCP", "externalSources": "外部 AI 應用", + "hooks": "Agent Hooks", "acpAgents": "ACP Agent", "agents": "智能體", "editor": "編輯器", diff --git a/src/web-ui/src/locales/zh-TW/settings/hooks.json b/src/web-ui/src/locales/zh-TW/settings/hooks.json new file mode 100644 index 0000000000..0132fb5ef4 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/hooks.json @@ -0,0 +1,45 @@ +{ + "title": "Agent Hooks", + "subtitle": "在 Agent 生命週期的固定節點執行你自己的命令 —— 工具呼叫前後、權限確認前後,以及工作階段與回合的邊界。", + "loading": "正在載入 Hook 設定...", + "activation": { + "title": "啟用", + "description": "每次對應事件觸發時,Hook 都會以你的使用者權限執行。啟用任何非你本人撰寫的 Hook 之前,請先審閱它。" + }, + "fields": { + "enabled": { + "label": "啟用 Hooks", + "description": "總開關。關閉後,任何事件都不會執行 Hook。" + }, + "projectHooks": { + "label": "允許專案層級 Hooks", + "description": "讀取工作區內的 Hook 檔案。它執行的是儲存庫中的命令,請只對你信任的儲存庫開啟。" + } + }, + "locations": { + "title": "Hook 宣告位置", + "description": "Hook 宣告在 hooks.json 檔案中,不在本設定文件裡。兩個層級是疊加關係,使用者層優先執行。修改後無需重新啟動 BitFun。", + "userFile": { + "label": "使用者 Hooks", + "description": "BitFun 使用者設定目錄下的 config/hooks.json(Linux 為 ~/.config/bitfun,macOS 為 ~/Library/Application Support/bitfun,Windows 為 %APPDATA%\\bitfun)。" + }, + "projectFile": { + "label": "專案 Hooks", + "description": "工作區內的 .bitfun/config/hooks.json。僅在開啟「允許專案層級 Hooks」時讀取。" + } + }, + "compatibility": { + "title": "Codex 相容", + "description": "hooks.json 文件結構、事件名稱、stdin 上的 JSON 載荷、結束碼語義以及 stdout 上的 JSON 決策結構均與 Codex Hooks 一致,因此 Codex 的 Hook 指令碼可以直接在這裡執行。", + "reference": { + "label": "事件與載荷參考", + "description": "事件名稱、載荷欄位和決策結構以 Codex 的 Hooks 文件為準。", + "open": "開啟 Codex Hooks 文件" + } + }, + "messages": { + "saved": "Hook 設定已儲存", + "saveFailed": "儲存 Hook 設定失敗", + "loadFailed": "載入 Hook 設定失敗" + } +}