From cace11a711769cca7cec1aa6494aa0c64f03a8fb Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 09:37:09 -0700 Subject: [PATCH 1/5] feat(hooks): add Codex-compatible agent lifecycle hooks BitFun could only inspect the hooks other AI applications had configured; it had no way to run a user's own command at an agent lifecycle point. Add native hooks that execute user-configured commands at eleven lifecycle events. The configuration document, event names, stdin payload fields, exit-code semantics, and stdout decision schema match the Codex hook contract, so an existing Codex hook script runs unchanged in either tool. - `bitfun-agent-runtime::native_hooks` owns the portable engine: settings parsing, matcher evaluation, payload construction, process execution with timeouts, and decision merging. - `bitfun-core::native_hooks` owns config discovery, gating, and one typed dispatch helper per event; the coordinator, execution engine, and tool pipeline call those helpers. - Hooks are read from `/config/hooks.json` and, when explicitly enabled, `/.bitfun/config/hooks.json`. Project hooks stay off by default because they execute commands from the checked-out repository. - A PreToolUse `allow` waives the interactive permission prompt only; a permission-policy deny still rejects the call, so hooks can narrow the policy but never widen it. - Hooks are skipped for remote workspaces, where the hook process and the workspace path would not describe the same filesystem. Documented in docs/features/agent-hooks.md (English) and docs/features/agent-hooks.zh-CN.md (Chinese), including the deviations from Codex and the payload fields that are not populated yet. Covered by 56 tests across settings parsing, payload construction, the process interface (real subprocesses for exit codes, timeouts, and stdout decisions), and product wiring. --- AGENTS-CN.md | 6 + AGENTS.md | 6 + README.md | 2 +- README.zh-CN.md | 2 +- docs/features/agent-hooks.md | 494 ++++++++++++++ docs/features/agent-hooks.zh-CN.md | 470 ++++++++++++++ .../src/agentic/coordination/coordinator.rs | 147 +++++ .../assembly/core/src/agentic/core/message.rs | 10 + .../src/agentic/execution/execution_engine.rs | 128 +++- .../agentic/tools/pipeline/state_manager.rs | 12 + .../agentic/tools/pipeline/tool_pipeline.rs | 187 +++++- .../infrastructure/app_paths/path_manager.rs | 15 +- src/crates/assembly/core/src/lib.rs | 4 + src/crates/assembly/core/src/native_hooks.rs | 566 ++++++++++++++++ .../assembly/core/src/native_hooks_tests.rs | 199 ++++++ .../assembly/core/src/service/config/types.rs | 29 + src/crates/execution/agent-runtime/src/lib.rs | 1 + .../agent-runtime/src/native_hooks/engine.rs | 257 ++++++++ .../agent-runtime/src/native_hooks/mod.rs | 33 + .../agent-runtime/src/native_hooks/output.rs | 180 +++++ .../agent-runtime/src/native_hooks/payload.rs | 252 +++++++ .../src/native_hooks/settings.rs | 613 ++++++++++++++++++ .../tests/native_hook_execution_contracts.rs | 472 ++++++++++++++ .../tests/native_hook_payload_contracts.rs | 349 ++++++++++ .../tests/native_hook_settings_contracts.rs | 385 +++++++++++ 25 files changed, 4813 insertions(+), 6 deletions(-) create mode 100644 docs/features/agent-hooks.md create mode 100644 docs/features/agent-hooks.zh-CN.md create mode 100644 src/crates/assembly/core/src/native_hooks.rs create mode 100644 src/crates/assembly/core/src/native_hooks_tests.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/engine.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/mod.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/output.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/payload.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/settings.rs create mode 100644 src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs create mode 100644 src/crates/execution/agent-runtime/tests/native_hook_payload_contracts.rs create mode 100644 src/crates/execution/agent-runtime/tests/native_hook_settings_contracts.rs diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 18923d472c..50aa40518d 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 + +- 用户可见行为记录在 [`docs/features/agent-hooks.zh-CN.md`](docs/features/agent-hooks.zh-CN.md)([English](docs/features/agent-hooks.md))。配置文档结构、事件名、stdin 载荷字段、退出码语义与 stdout 决策结构都与 Codex 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..689ef7750a 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 + +- User-facing behavior is documented in [`docs/features/agent-hooks.md`](docs/features/agent-hooks.md) ([中文](docs/features/agent-hooks.zh-CN.md)). The configuration document, event names, stdin payload fields, exit-code semantics, and stdout decision schema are kept consistent with Codex hooks; do not diverge from that contract without updating both documents. +- 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..2c5ec00a9c 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 (Codex-compatible). | | **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..b82d09514b 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 兼容)。 | | **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..98197dba6f --- /dev/null +++ b/docs/features/agent-hooks.md @@ -0,0 +1,494 @@ +# 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. + +The `hooks.json` document, the event names, the JSON payload on stdin, the +exit-code meanings, and the JSON decision schema on stdout are **the same as +Codex hooks**, so a Codex hook script runs in BitFun unchanged and vice versa. +What differs is where the files live and which extras are supported: + +| | Codex | BitFun | +| --- | --- | --- | +| User hooks | `~/.codex/hooks.json` | `/config/hooks.json` | +| Project hooks | `/.codex/hooks.json` | `/.bitfun/config/hooks.json` | +| Hooks inside the main config | `config.toml` `[hooks]` table | not supported — use `hooks.json` | +| Master switch | `[features] hooks = false` | `app.hooks.enabled` in `app.json` | +| Plugin-bundled and managed hooks | supported | not supported | + +Some payload fields are also not populated yet; see +[Current gaps](#current-gaps). + +> Scope: local workspaces. Hooks are skipped for remote SSH and container +> workspaces, because a local hook process and a remote workspace path do not +> describe the same filesystem. +> +> Non-goal: `prompt` and `agent` handler types. BitFun parses them (so shared +> configuration files stay valid) but only executes `type: "command"` handlers. + +## Quick start + +1. Create `hooks.json` in your user config directory: + + | Platform | Path | + | --- | --- | + | Linux | `~/.config/bitfun/config/hooks.json` | + | macOS | `~/Library/Application Support/bitfun/config/hooks.json` | + | Windows | `%APPDATA%\bitfun\config\hooks.json` | + +2. Add a hook that logs every shell command the Agent runs: + + ```json + { + "description": "My hooks", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" + } + ] + } + ] + } + } + ``` + +3. Start a new BitFun session and ask the Agent to run a shell command. Each + command it runs is appended to `~/bitfun-commands.log`. + +Configuration is re-read when the file changes — there is no need to restart +BitFun after editing `hooks.json`. + +## Configuration + +### File locations and layering + +Hooks are read from two layers, in this order: + +| Order | Scope | Path | +| --- | --- | --- | +| 1 | User | `/config/hooks.json` (see the table above) | +| 2 | Project | `/.bitfun/config/hooks.json` | + +Layers are additive: every matching handler from both layers runs, user +handlers first. There is no override or shadowing between layers. + +**Project hooks are disabled 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. Enable them only for +workspaces you trust, 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 `/.bitfun/config/hooks.json` is honored. | + +### Document schema + +```json +{ + "description": "optional, free text", + "hooks": { + "": [ + { + "matcher": "optional pattern", + "hooks": [ + { + "type": "command", + "command": "the command to run", + "commandWindows": "optional Windows override", + "timeout": 30, + "statusMessage": "optional label" + } + ] + } + ] + } +} +``` + +The root object accepts only `description` and `hooks`. Any other root key +rejects the whole file — this matches Codex and catches typos early rather than +silently ignoring your configuration. + +Each event maps to a list of **matcher groups**. Each group has an optional +`matcher` and a required `hooks` array of handlers. + +#### Handler fields + +| Field | Required | Meaning | +| --- | --- | --- | +| `type` | yes | Must be `"command"` to execute. `"prompt"` and `"agent"` are accepted but skipped. | +| `command` | yes | Run through `sh -c` (Unix) or `cmd /C` (Windows), with the workspace root as the working directory. | +| `commandWindows` | no | Used instead of `command` on Windows. | +| `timeout` | no | Seconds. Defaults to 600 (`SessionEnd`: 1, capped at 3). | +| `statusMessage` | no | Short label describing what the hook does. | + +#### Matcher semantics + +A matcher filters which handlers run, based on one value per event (see the +event table). Matchers are regular expressions anchored to the whole value, so +a plain name is an exact match. + +| Matcher | Matches | +| --- | --- | +| omitted, `""`, or `"*"` | everything | +| `"Bash"` | exactly `Bash` (not `BashOutput`) | +| `"^Bash$"` | exactly `Bash` | +| `"Edit\|Write"` | `Edit` or `Write` | +| `"mcp__filesystem__.*"` | every tool in that MCP server | +| `"startup"` | that `SessionStart` source (see [Current gaps](#current-gaps)) | + +A matcher that is not a valid pattern (or is not a string) never matches +anything, and is reported in the logs. It is not silently treated as +match-all. + +### Limits + +| Limit | Value | +| --- | --- | +| Maximum size per `hooks.json` | 1 MiB | +| Maximum handlers inspected across all layers | 2048 (invalid and non-`command` handlers count toward it) | +| Maximum model-visible text per hook | 10,000 bytes (truncated with a marker) | + +## Events + +| Event | Fires when | Matcher value | Can block? | +| --- | --- | --- | --- | +| `SessionStart` | a session is created | `source` (currently always `startup`) | no | +| `SessionEnd` | a session is deleted | — | no | +| `UserPromptSubmit` | a prompt is submitted, before the turn starts | — | yes — rejects the prompt | +| `PreToolUse` | a tool call is scheduled, before permission evaluation and before the tool runs | `tool_name` | yes — denies the tool call | +| `PermissionRequest` | a tool call is about to prompt you for permission | `tool_name` | yes — allows or denies instead of prompting | +| `PostToolUse` | a tool call returned a result (successful or error) | `tool_name` | feedback only (appended to the tool result) | +| `PreCompact` | before context compaction | `trigger` (`auto`, `manual`) | no | +| `PostCompact` | after context compaction | `trigger` | no | +| `SubagentStart` | a subagent turn begins | `agent_type` | no | +| `SubagentStop` | a subagent turn settles successfully | `agent_type` | recorded in logs only | +| `Stop` | a top-level turn is about to finish with a final answer | — | yes — reopens the turn (max 3 times) | + +Unknown event names are ignored with a warning; the rest of the file still +loads. + +### Current gaps + +These are real differences from the Codex contract in this release. They +affect what a hook can rely on, so they are listed rather than implied: + +| Field or event | Current behavior | +| --- | --- | +| `transcript_path`, `agent_transcript_path` | always `null` — session transcripts are not exposed to hooks yet | +| `permission_mode` | only `default` or `bypassPermissions`; the other Codex modes never appear | +| `SessionStart.source` | only `startup`; `resume`, `clear`, and `compact` are not dispatched yet | +| `SessionEnd.reason` | always `other` | +| `SubagentStop.stop_hook_active` | always `false` — `SubagentStop` never reopens a subagent turn | +| `SubagentStop` | dispatched when a subagent settles successfully; a failed, cancelled, or timed-out subagent does not dispatch it | +| `Stop` | top-level turns only; subagent turns report through `SubagentStop` | + +## The hook process interface + +### Input: JSON on stdin + +Every payload contains these fields: + +```json +{ + "session_id": "string", + "transcript_path": "string or null", + "cwd": "string", + "hook_event_name": "string", + "model": "string", + "permission_mode": "default | acceptEdits | plan | dontAsk | bypassPermissions" +} +``` + +Every event except `SessionStart` and `SessionEnd` also carries `turn_id`. +Each event then adds its own fields: + +| Event | Additional fields | +| --- | --- | +| `SessionStart` | `source` | +| `SessionEnd` | `reason` | +| `UserPromptSubmit` | `prompt` | +| `PreToolUse` | `tool_name`, `tool_use_id`, `tool_input` | +| `PermissionRequest` | `tool_name`, `tool_input` | +| `PostToolUse` | `tool_name`, `tool_use_id`, `tool_input`, `tool_response` | +| `PreCompact` / `PostCompact` | `trigger` | +| `SubagentStart` | `agent_id`, `agent_type` | +| `SubagentStop` | `agent_id`, `agent_type`, `agent_transcript_path`, `stop_hook_active`, `last_assistant_message` | +| `Stop` | `stop_hook_active`, `last_assistant_message` | + +`stop_hook_active` is `true` when the Agent is already continuing because a +`Stop` hook blocked an earlier attempt to finish. Check it to avoid blocking +forever; BitFun independently caps reopenings at three per turn. + +### Output: exit code, and optional JSON on stdout + +| Exit code | Meaning | +| --- | --- | +| `0` | Success. If stdout parses as JSON, it is read as a decision document (below). | +| `2` | Block the event. **stderr** is the reason shown to the Agent. | +| anything else | Non-blocking error: a warning is logged and the Agent continues. | + +A hook that fails to start, or exceeds its timeout, is also a non-blocking +warning — a broken hook slows the Agent down but never wedges it. + +For `SessionStart`, `UserPromptSubmit`, and `SubagentStart`, plain (non-JSON) +stdout on exit 0 becomes context the model reads. For all other events, plain +stdout is ignored, so `echo` debugging never leaks into the conversation. + +#### Decision document + +All fields are optional: + +```json +{ + "continue": true, + "stopReason": "shown when continue is false", + "systemMessage": "logged for you, not sent to the model", + "suppressOutput": false, + "decision": "block", + "reason": "why the event was blocked", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow | deny", + "permissionDecisionReason": "string", + "updatedInput": { "command": "the rewritten arguments" }, + "additionalContext": "text the model will read", + "decision": { "behavior": "allow | deny", "message": "string" } + } +} +``` + +Which fields apply per event: + +| Event | Fields it honors | +| --- | --- | +| `PreToolUse` | `permissionDecision` (`allow` skips the permission prompt, `deny` blocks the call), `permissionDecisionReason`, `updatedInput`, and `continue: false` (treated as a denial of that tool call) | +| `PermissionRequest` | `decision.behavior` with `decision.message` | +| `PostToolUse` | `decision: "block"` with `reason`, and `additionalContext` — both appended to the tool result the model reads | +| `UserPromptSubmit` | `decision: "block"` with `reason` (rejects the prompt), `additionalContext`, and `continue: false` (also rejects the prompt) | +| `Stop` / `SubagentStop` | `decision: "block"` with `reason` | +| any | `systemMessage` (written to the BitFun log, never sent to the model) | + +`permissionDecision: "allow"` waives the interactive permission prompt only. +A tool call that a permission rule denies stays denied — a hook can never +widen the permission policy, only narrow it. + +Two fields are accepted for Codex compatibility but currently have no effect +beyond the rows above: `suppressOutput` is parsed and ignored, and +`continue`/`stopReason` are honored only for `PreToolUse` and +`UserPromptSubmit` (elsewhere, use `decision: "block"` to stop the event). + +When several handlers match one event, they run in order (user layer first) +and their results merge: the first blocking or denying decision stops the +remaining handlers, a `deny` outranks an earlier `allow`, a later +`updatedInput` replaces an earlier one, and context and system messages +accumulate. + +## Examples + +### Block edits to protected paths + +`PreToolUse` returning a deny decision. The Agent is told why, and continues +the turn without the edit. + +```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) +path = payload.get("tool_input", {}).get("file_path", "") + +if "/migrations/" in path: + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "Migrations are generated; edit the schema instead.", + } + })) +sys.exit(0) +``` + +### Format and report after every write + +`PostToolUse` returning feedback the model reads. Note that plain stdout and +stderr are ignored for `PostToolUse` — feedback must be `additionalContext` +in a JSON decision document, so the hook is a small script rather than a +one-liner. + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash ~/hooks/format.sh", + "timeout": 60, + "statusMessage": "Formatting Rust code" + } + ] + } + ] + } +} +``` + +```bash +#!/usr/bin/env bash +# ~/hooks/format.sh +cat >/dev/null # drain the payload +output=$(cargo fmt 2>&1 | head -20) +[ -z "$output" ] && exit 0 # nothing to report +jq -n --arg ctx "cargo fmt output:\n$output" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}' +``` + +### Require a clean test run before finishing + +`Stop` blocking with exit code 2. The stderr text becomes the reason, and the +Agent keeps working instead of finishing. + +```bash +#!/usr/bin/env bash +# ~/hooks/require-tests.sh +payload=$(cat) +if [ "$(printf '%s' "$payload" | jq -r '.stop_hook_active')" = "true" ]; then + exit 0 # already reopened once; don't loop +fi +if ! cargo test --quiet >/tmp/hook-tests.log 2>&1; then + echo "Tests are failing. See /tmp/hook-tests.log, fix them, then finish." >&2 + exit 2 +fi +``` + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "bash ~/hooks/require-tests.sh", "timeout": 300 } + ] + } + ] + } +} +``` + +### Inject project context at session start + +Plain stdout from `SessionStart` becomes context the model reads. + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "git log --oneline -5 && git status --short" } + ] + } + ] + } +} +``` + +### Auto-approve a safe tool without prompting + +`PermissionRequest` deciding instead of asking you. + +```json +{ + "hooks": { + "PermissionRequest": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\",\"message\":\"Reads are always allowed\"}}}'" + } + ] + } + ] + } +} +``` + +## 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 `app.hooks.project_hooks_enabled` off unless you trust everyone who can + commit to the repository, and re-check the project hook file after pulling. +- 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 — the examples above read fields with `jq`/`json.load` for + exactly this reason. +- Hooks inherit BitFun's environment. Do not print secrets to stdout for + context events, where the text is sent to the model. + +## 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. | +| 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 every hook decision are +written to the BitFun backend log. See +[`src/crates/LOGGING.md`](../../src/crates/LOGGING.md) for how to raise the log +level. + +## Related + +- [`/hooks`](../../src/apps/cli/src/modes/chat/external_hooks.rs) in the CLI + inspects hooks configured for *other* AI applications (Claude Code, Codex, + OpenCode). That view is read-only and never executes anything; the hooks + described in this document 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..fb3ef6dd51 --- /dev/null +++ b/docs/features/agent-hooks.zh-CN.md @@ -0,0 +1,470 @@ +# Agent Hooks(生命周期钩子) + +Hooks 让你在 BitFun Agent 生命周期的固定节点运行自己的命令:工具调用前后、 +即将弹出权限确认时、提交提示词时、上下文压缩前后、子 Agent 启动与结束时, +以及会话与回合的开始与结束。一个 Hook 可以观察 Agent 的行为、注入模型可见的 +上下文、改写工具调用参数,或者直接阻止某个动作。 + +`hooks.json` 文档结构、事件名、stdin 上的 JSON 载荷、退出码语义以及 stdout 上的 +JSON 决策结构**与 Codex Hooks 一致**,因此 Codex 的 Hook 脚本可以直接在 BitFun +中运行,反之亦然。差异在于文件位置和部分扩展能力: + +| | Codex | BitFun | +| --- | --- | --- | +| 用户 Hooks | `~/.codex/hooks.json` | `<用户配置目录>/config/hooks.json` | +| 项目 Hooks | `<仓库>/.codex/hooks.json` | `<工作区>/.bitfun/config/hooks.json` | +| 主配置内联 Hooks | `config.toml` 的 `[hooks]` 表 | 不支持 —— 请使用 `hooks.json` | +| 总开关 | `[features] hooks = false` | `app.json` 中的 `app.hooks.enabled` | +| 插件内置与托管 Hooks | 支持 | 不支持 | + +部分载荷字段目前也尚未填充,见[当前差距](#当前差距)。 + +> 适用范围:本地工作区。远程 SSH 与容器工作区会跳过 Hooks,因为本地 Hook 进程与 +> 远程工作区路径描述的并不是同一个文件系统。 +> +> 非目标:`prompt` 与 `agent` 处理器类型。BitFun 会解析它们(以便共享的配置文件 +> 保持有效),但只执行 `type: "command"` 处理器。 + +## 快速开始 + +1. 在用户配置目录下创建 `hooks.json`: + + | 平台 | 路径 | + | --- | --- | + | Linux | `~/.config/bitfun/config/hooks.json` | + | macOS | `~/Library/Application Support/bitfun/config/hooks.json` | + | Windows | `%APPDATA%\bitfun\config\hooks.json` | + +2. 添加一个记录 Agent 执行过的所有 shell 命令的 Hook: + + ```json + { + "description": "My hooks", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" + } + ] + } + ] + } + } + ``` + +3. 新建一个 BitFun 会话,让 Agent 执行一条 shell 命令。它执行的每条命令都会追加到 + `~/bitfun-commands.log`。 + +配置文件变化后会自动重新读取,编辑 `hooks.json` 之后无需重启 BitFun。 + +## 配置 + +### 文件位置与分层 + +Hooks 按以下顺序从两个层级读取: + +| 顺序 | 层级 | 路径 | +| --- | --- | --- | +| 1 | 用户 | `<用户配置目录>/config/hooks.json`(见上表) | +| 2 | 项目 | `<工作区>/.bitfun/config/hooks.json` | + +两个层级是叠加关系:两层中所有匹配的处理器都会执行,用户层优先。层级之间不存在 +覆盖或屏蔽关系。 + +**项目级 Hooks 默认关闭。** 项目 Hook 文件执行的是仓库中的命令,任何能提交代码的 +人都可能借此在你的机器上执行代码。只对你信任的工作区开启,在 +`<用户配置目录>/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` | 是否启用 `<工作区>/.bitfun/config/hooks.json`。 | + +### 文档结构 + +```json +{ + "description": "可选,自由文本", + "hooks": { + "<事件名>": [ + { + "matcher": "可选的匹配模式", + "hooks": [ + { + "type": "command", + "command": "要执行的命令", + "commandWindows": "可选的 Windows 覆盖命令", + "timeout": 30, + "statusMessage": "可选的说明文本" + } + ] + } + ] + } +} +``` + +根对象只接受 `description` 和 `hooks`。出现其他根级字段会导致整个文件被拒绝 —— 这 +与 Codex 行为一致,可以尽早暴露拼写错误,而不是静默忽略你的配置。 + +每个事件对应一组**匹配组**。每个匹配组包含可选的 `matcher` 和必需的处理器数组 +`hooks`。 + +#### 处理器字段 + +| 字段 | 必需 | 含义 | +| --- | --- | --- | +| `type` | 是 | 必须为 `"command"` 才会执行。`"prompt"` 和 `"agent"` 会被接受但跳过。 | +| `command` | 是 | 通过 `sh -c`(Unix)或 `cmd /C`(Windows)执行,工作目录为工作区根目录。 | +| `commandWindows` | 否 | 在 Windows 上代替 `command` 使用。 | +| `timeout` | 否 | 单位为秒。默认 600(`SessionEnd` 默认 1,上限 3)。 | +| `statusMessage` | 否 | 简短说明该 Hook 的用途。 | + +#### 匹配规则 + +matcher 根据事件对应的一个值筛选要执行的处理器(见事件表)。matcher 是对整个值做 +锚定匹配的正则表达式,因此直接写名字就是精确匹配。 + +| matcher | 匹配范围 | +| --- | --- | +| 省略、`""` 或 `"*"` | 全部匹配 | +| `"Bash"` | 精确匹配 `Bash`(不匹配 `BashOutput`) | +| `"^Bash$"` | 精确匹配 `Bash` | +| `"Edit\|Write"` | `Edit` 或 `Write` | +| `"mcp__filesystem__.*"` | 该 MCP 服务下的所有工具 | +| `"startup"` | 对应的 `SessionStart` 来源(见[当前差距](#当前差距)) | + +无效的 matcher(不是合法模式,或不是字符串)不会匹配任何内容,并会记录到日志。 +它不会被当作"匹配全部"处理。 + +### 限制 + +| 限制项 | 取值 | +| --- | --- | +| 单个 `hooks.json` 最大体积 | 1 MiB | +| 所有层级可检查处理器总数上限 | 2048(无效处理器和非 `command` 处理器同样计入) | +| 单个 Hook 的模型可见文本上限 | 10,000 字节(超出会截断并标记) | + +## 事件 + +| 事件 | 触发时机 | matcher 取值 | 能否阻止? | +| --- | --- | --- | --- | +| `SessionStart` | 创建会话时 | `source`(当前恒为 `startup`) | 否 | +| `SessionEnd` | 删除会话时 | — | 否 | +| `UserPromptSubmit` | 提交提示词后、回合开始前 | — | 可以 —— 拒绝该提示词 | +| `PreToolUse` | 工具调用被排入执行前,早于权限评估与工具运行 | `tool_name` | 可以 —— 拒绝该工具调用 | +| `PermissionRequest` | 工具调用即将向你请求权限时 | `tool_name` | 可以 —— 直接放行或拒绝,不再弹窗 | +| `PostToolUse` | 工具调用返回结果后(成功或报错都会触发) | `tool_name` | 仅反馈(追加到工具结果中) | +| `PreCompact` | 上下文压缩之前 | `trigger`(`auto`、`manual`) | 否 | +| `PostCompact` | 上下文压缩之后 | `trigger` | 否 | +| `SubagentStart` | 子 Agent 回合开始时 | `agent_type` | 否 | +| `SubagentStop` | 子 Agent 回合成功结束时 | `agent_type` | 仅记录到日志 | +| `Stop` | 顶层回合即将以最终回答结束时 | — | 可以 —— 重新打开该回合(最多 3 次) | + +未知事件名会被忽略并记录警告,文件其余部分仍然生效。 + +### 当前差距 + +以下是本版本与 Codex 契约的真实差异。它们会影响 Hook 能依赖的内容,因此明确列出 +而非含糊带过: + +| 字段或事件 | 当前行为 | +| --- | --- | +| `transcript_path`、`agent_transcript_path` | 恒为 `null` —— 会话转录尚未开放给 Hook | +| `permission_mode` | 只会是 `default` 或 `bypassPermissions`,其余 Codex 模式不会出现 | +| `SessionStart.source` | 只有 `startup`;`resume`、`clear`、`compact` 尚未派发 | +| `SessionEnd.reason` | 恒为 `other` | +| `SubagentStop.stop_hook_active` | 恒为 `false` —— `SubagentStop` 不会重新打开子 Agent 回合 | +| `SubagentStop` | 仅在子 Agent 成功结束时派发;失败、取消或超时不会派发 | +| `Stop` | 仅顶层回合触发;子 Agent 回合通过 `SubagentStop` 上报 | + +## Hook 进程接口 + +### 输入:stdin 上的 JSON + +每个载荷都包含以下字段: + +```json +{ + "session_id": "string", + "transcript_path": "string 或 null", + "cwd": "string", + "hook_event_name": "string", + "model": "string", + "permission_mode": "default | acceptEdits | plan | dontAsk | bypassPermissions" +} +``` + +除 `SessionStart` 和 `SessionEnd` 之外的事件还会带上 `turn_id`。各事件另有自己的 +字段: + +| 事件 | 附加字段 | +| --- | --- | +| `SessionStart` | `source` | +| `SessionEnd` | `reason` | +| `UserPromptSubmit` | `prompt` | +| `PreToolUse` | `tool_name`、`tool_use_id`、`tool_input` | +| `PermissionRequest` | `tool_name`、`tool_input` | +| `PostToolUse` | `tool_name`、`tool_use_id`、`tool_input`、`tool_response` | +| `PreCompact` / `PostCompact` | `trigger` | +| `SubagentStart` | `agent_id`、`agent_type` | +| `SubagentStop` | `agent_id`、`agent_type`、`agent_transcript_path`、`stop_hook_active`、`last_assistant_message` | +| `Stop` | `stop_hook_active`、`last_assistant_message` | + +当 Agent 已经因为某个 `Stop` Hook 的阻止而继续运行时,`stop_hook_active` 为 +`true`。检查这个字段可以避免无限阻止;BitFun 本身也会将同一回合的重新打开次数 +限制为 3 次。 + +### 输出:退出码,以及可选的 stdout JSON + +| 退出码 | 含义 | +| --- | --- | +| `0` | 成功。若 stdout 能解析为 JSON,则按决策文档解读(见下)。 | +| `2` | 阻止该事件。**stderr** 的内容作为原因传给 Agent。 | +| 其他 | 非阻塞错误:记录警告,Agent 继续运行。 | + +启动失败或超时的 Hook 同样只产生非阻塞警告 —— 出问题的 Hook 会让 Agent 变慢, +但不会把它卡死。 + +对 `SessionStart`、`UserPromptSubmit`、`SubagentStart` 而言,退出码为 0 时非 JSON +的普通 stdout 会成为模型可见的上下文。其他事件会忽略普通 stdout,因此用 `echo` +调试不会泄漏进对话。 + +#### 决策文档 + +所有字段均为可选: + +```json +{ + "continue": true, + "stopReason": "continue 为 false 时展示", + "systemMessage": "记录给你看,不发送给模型", + "suppressOutput": false, + "decision": "block", + "reason": "被阻止的原因", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow | deny", + "permissionDecisionReason": "string", + "updatedInput": { "command": "改写后的参数" }, + "additionalContext": "模型会读到的文本", + "decision": { "behavior": "allow | deny", "message": "string" } + } +} +``` + +各事件生效的字段: + +| 事件 | 生效字段 | +| --- | --- | +| `PreToolUse` | `permissionDecision`(`allow` 跳过权限确认,`deny` 阻止调用)、`permissionDecisionReason`、`updatedInput`,以及 `continue: false`(按拒绝该工具调用处理) | +| `PermissionRequest` | `decision.behavior` 及 `decision.message` | +| `PostToolUse` | `decision: "block"` 加 `reason`,以及 `additionalContext` —— 两者都会追加到模型读到的工具结果中 | +| `UserPromptSubmit` | `decision: "block"` 加 `reason`(拒绝该提示词)、`additionalContext`,以及 `continue: false`(同样拒绝该提示词) | +| `Stop` / `SubagentStop` | `decision: "block"` 加 `reason` | +| 任意事件 | `systemMessage`(写入 BitFun 日志,不会发送给模型) | + +`permissionDecision: "allow"` 只会免去交互式权限确认。被权限规则拒绝的工具调用 +依然会被拒绝 —— Hook 只能收紧权限策略,永远无法放宽。 + +以下两个字段为兼容 Codex 而被接受,但当前除上表所列外没有实际作用: +`suppressOutput` 会被解析但忽略;`continue`/`stopReason` 仅对 `PreToolUse` 和 +`UserPromptSubmit` 生效(其他事件请使用 `decision: "block"` 来阻止)。 + +当一个事件匹配到多个处理器时,它们按顺序执行(用户层优先),结果会合并:第一个 +阻止或拒绝的决策会终止其余处理器;`deny` 覆盖此前的 `allow`;后出现的 +`updatedInput` 替换先前的;上下文与系统消息会累加。 + +## 示例 + +### 阻止修改受保护路径 + +`PreToolUse` 返回拒绝决策。Agent 会得知原因,并在没有该编辑的情况下继续回合。 + +```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) +path = payload.get("tool_input", {}).get("file_path", "") + +if "/migrations/" in path: + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "迁移文件由生成器产出,请改动 schema。", + } + })) +sys.exit(0) +``` + +### 每次写入后格式化并回报 + +`PostToolUse` 返回模型可读的反馈。注意 `PostToolUse` 会忽略普通 stdout 和 +stderr —— 反馈必须通过 JSON 决策文档中的 `additionalContext` 给出,因此这里用 +一个小脚本而不是一行命令。 + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash ~/hooks/format.sh", + "timeout": 60, + "statusMessage": "Formatting Rust code" + } + ] + } + ] + } +} +``` + +```bash +#!/usr/bin/env bash +# ~/hooks/format.sh +cat >/dev/null # 读掉载荷 +output=$(cargo fmt 2>&1 | head -20) +[ -z "$output" ] && exit 0 # 没有需要回报的内容 +jq -n --arg ctx "cargo fmt output:\n$output" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}' +``` + +### 测试通过后才允许结束 + +`Stop` 通过退出码 2 阻止结束,stderr 文本作为原因,Agent 会继续工作而不是收尾。 + +```bash +#!/usr/bin/env bash +# ~/hooks/require-tests.sh +payload=$(cat) +if [ "$(printf '%s' "$payload" | jq -r '.stop_hook_active')" = "true" ]; then + exit 0 # 已经重新打开过一次,不要循环 +fi +if ! cargo test --quiet >/tmp/hook-tests.log 2>&1; then + echo "测试未通过。请查看 /tmp/hook-tests.log,修复后再结束。" >&2 + exit 2 +fi +``` + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "bash ~/hooks/require-tests.sh", "timeout": 300 } + ] + } + ] + } +} +``` + +### 会话开始时注入项目上下文 + +`SessionStart` 的普通 stdout 会成为模型可见的上下文。 + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "git log --oneline -5 && git status --short" } + ] + } + ] + } +} +``` + +### 自动放行安全工具,不再弹窗 + +`PermissionRequest` 代替你做决定。 + +```json +{ + "hooks": { + "PermissionRequest": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\",\"message\":\"Reads are always allowed\"}}}'" + } + ] + } + ] + } +} +``` + +## 安全 + +Hook 是以你的用户权限运行的任意代码,且每次对应事件触发都会运行。请像对待 shell +配置文件那样对待 `hooks.json`: + +- 启用任何非你本人编写的 Hook 之前先审阅它。 +- 除非你信任所有能向仓库提交代码的人,否则保持 `app.hooks.project_hooks_enabled` + 关闭;拉取代码后重新检查项目 Hook 文件。 +- 载荷中的值(提示词、工具参数、文件路径)是模型和用户提供的文本。请按 JSON 解析, + 不要拼接进 shell 命令 —— 上面的示例正是为此用 `jq` / `json.load` 读取字段。 +- Hook 继承 BitFun 的环境变量。对于上下文类事件,不要把密钥打印到 stdout,因为这些 + 文本会发送给模型。 + +## 排查 + +| 现象 | 原因 | +| --- | --- | +| 完全没有 Hook 运行 | `app.hooks.enabled` 为 `false`、文件不在文档所述路径,或工作区是远程工作区。 | +| 项目 Hooks 不运行 | `app.hooks.project_hooks_enabled` 为 `false`(默认值)。 | +| 整个文件被忽略 | JSON 无效,或存在 `description`/`hooks` 之外的根级字段。 | +| 某个事件被忽略 | 事件名拼写错误 —— 事件名区分大小写。 | +| 某个处理器从不运行 | matcher 不匹配,或 matcher 不是合法模式。 | +| `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`](../../src/apps/cli/src/modes/chat/external_hooks.rs) 用于查看 + *其他* 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..9b5fc2b076 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, 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..9801a09f22 --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs @@ -0,0 +1,472 @@ +//! 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_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, + ] + ); +} From fe14d7f23594d6fcce82590fd521f2dae71533d2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 09:49:20 -0700 Subject: [PATCH 2/5] test(hooks): pin that a hook approval cannot widen the permission policy A PreToolUse hook approval waives the interactive permission prompt only. Cover both halves of that invariant: a policy Deny still rejects the call and the tool never runs, while an Ask-only policy proceeds without raising a permission request. --- .../agentic/tools/pipeline/tool_pipeline.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) 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 9b5fc2b076..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 @@ -3156,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()); From eac73bb444fec8846d347b955cdd265d4a30c28d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 10:11:31 -0700 Subject: [PATCH 3/5] test(hooks): cover the stdin/stdout deadlock a hook can provoke A handler that reads stdin and echoes it back blocks on its own stdout once the payload exceeds the pipe buffer in both directions. Writing the payload before waiting for the child would deadlock there; the engine drives the write and the wait concurrently, and this pins that. --- .../tests/native_hook_execution_contracts.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 index 9801a09f22..9c85ae9e64 100644 --- a/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs @@ -400,6 +400,31 @@ async fn a_hook_that_never_reads_a_large_payload_still_times_out() { ); } +#[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. From dc3cddc67caaa3f7db40bb7f71386b7ab76d02e4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 10:21:00 -0700 Subject: [PATCH 4/5] feat(hooks): add an Agent Hooks settings page, and point docs at Codex The hooks capability had no UI: the enablement gates could only be reached by hand-editing app.json. Add a settings page under Smart Capabilities that exposes both gates, explains where hooks.json lives, and links out to the Codex hook reference. Also rewrite the feature docs around the fact that BitFun implements the Codex hook contract rather than a dialect of it. The docs now lead with that compatibility, send readers to https://learn.chatgpt.com/docs/hooks for the event, payload, and decision reference, and keep only what is BitFun-specific: file locations, the app.hooks gates, and an explicit deviations table. That halves their length and removes a second copy of a reference that would otherwise drift out of sync with upstream. --- AGENTS-CN.md | 2 +- AGENTS.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/features/agent-hooks.md | 475 ++++-------------- docs/features/agent-hooks.zh-CN.md | 439 ++++------------ .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsConfig.ts | 16 + .../settings/settingsContentRegistry.ts | 3 + .../config/components/HooksConfig.tsx | 172 +++++++ .../i18n/presets/namespaceRegistry.ts | 1 + src/web-ui/src/locales/en-US/settings.json | 2 + .../src/locales/en-US/settings/hooks.json | 45 ++ src/web-ui/src/locales/zh-CN/settings.json | 2 + .../src/locales/zh-CN/settings/hooks.json | 45 ++ src/web-ui/src/locales/zh-TW/settings.json | 2 + .../src/locales/zh-TW/settings/hooks.json | 45 ++ 17 files changed, 521 insertions(+), 736 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/HooksConfig.tsx create mode 100644 src/web-ui/src/locales/en-US/settings/hooks.json create mode 100644 src/web-ui/src/locales/zh-CN/settings/hooks.json create mode 100644 src/web-ui/src/locales/zh-TW/settings/hooks.json diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 50aa40518d..c72b2df44c 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -173,7 +173,7 @@ await api.invoke('your_command', { request: { ... } }); ### Agent Hooks -- 用户可见行为记录在 [`docs/features/agent-hooks.zh-CN.md`](docs/features/agent-hooks.zh-CN.md)([English](docs/features/agent-hooks.md))。配置文档结构、事件名、stdin 载荷字段、退出码语义与 stdout 决策结构都与 Codex 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 689ef7750a..b732db70fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,7 @@ await api.invoke('your_command', { request: { ... } }); ### Agent hooks -- User-facing behavior is documented in [`docs/features/agent-hooks.md`](docs/features/agent-hooks.md) ([中文](docs/features/agent-hooks.zh-CN.md)). The configuration document, event names, stdin payload fields, exit-code semantics, and stdout decision schema are kept consistent with Codex hooks; do not diverge from that contract without updating both documents. +- 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. diff --git a/README.md b/README.md index 2c5ec00a9c..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 / [Hooks](docs/features/agent-hooks.md) | Connecting external tools and professional capabilities, and running your own commands at Agent lifecycle points (Codex-compatible). | +| **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 b82d09514b..f8c9c68978 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,7 +111,7 @@ BitFun 的扩展路径从轻到重连续展开: | 层级 | 方式 | 适合场景 | | --- | --- | --- | | **L1** | Agent 自定义 | 定义角色、流程、约束和工具组合。 | -| **L2** | MCP / Skills / [Hooks](docs/features/agent-hooks.zh-CN.md) | 接入外部工具和专业能力,并在 Agent 生命周期节点运行你自己的命令(与 Codex 兼容)。 | +| **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 index 98197dba6f..fc7b17cb22 100644 --- a/docs/features/agent-hooks.md +++ b/docs/features/agent-hooks.md @@ -7,84 +7,53 @@ 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. -The `hooks.json` document, the event names, the JSON payload on stdin, the -exit-code meanings, and the JSON decision schema on stdout are **the same as -Codex hooks**, so a Codex hook script runs in BitFun unchanged and vice versa. -What differs is where the files live and which extras are supported: +## BitFun hooks are Codex hooks -| | Codex | BitFun | -| --- | --- | --- | -| User hooks | `~/.codex/hooks.json` | `/config/hooks.json` | -| Project hooks | `/.codex/hooks.json` | `/.bitfun/config/hooks.json` | -| Hooks inside the main config | `config.toml` `[hooks]` table | not supported — use `hooks.json` | -| Master switch | `[features] hooks = false` | `app.hooks.enabled` in `app.json` | -| Plugin-bundled and managed hooks | supported | not supported | - -Some payload fields are also not populated yet; see -[Current gaps](#current-gaps). - -> Scope: local workspaces. Hooks are skipped for remote SSH and container -> workspaces, because a local hook process and a remote workspace path do not -> describe the same filesystem. -> -> Non-goal: `prompt` and `agent` handler types. BitFun parses them (so shared -> configuration files stay valid) but only executes `type: "command"` handlers. - -## Quick start +BitFun implements **the Codex hook contract**, not a BitFun dialect: -1. Create `hooks.json` in your user config directory: +- 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`, …). - | Platform | Path | - | --- | --- | - | Linux | `~/.config/bitfun/config/hooks.json` | - | macOS | `~/Library/Application Support/bitfun/config/hooks.json` | - | Windows | `%APPDATA%\bitfun\config\hooks.json` | +**A Codex hook script runs in BitFun unchanged, and vice versa — there is +nothing to port.** -2. Add a hook that logs every shell command the Agent runs: +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: - ```json - { - "description": "My hooks", - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" - } - ] - } - ] - } - } - ``` +**→ ** -3. Start a new BitFun session and ask the Agent to run a shell command. Each - command it runs is appended to `~/bitfun-commands.log`. +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. -Configuration is re-read when the file changes — there is no need to restart -BitFun after editing `hooks.json`. +## Where BitFun reads hooks -## Configuration +Codex reads `~/.codex/hooks.json`; BitFun reads its own config directory +instead. Everything inside the file is identical. -### File locations and layering +| Scope | Path | +| --- | --- | +| User | `/config/hooks.json` | +| Project | `/.bitfun/config/hooks.json` | -Hooks are read from two layers, in this order: +The user config directory is `~/.config/bitfun` on Linux, +`~/Library/Application Support/bitfun` on macOS, and `%APPDATA%\bitfun` on +Windows. -| Order | Scope | Path | -| --- | --- | --- | -| 1 | User | `/config/hooks.json` (see the table above) | -| 2 | Project | `/.bitfun/config/hooks.json` | +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. -Layers are additive: every matching handler from both layers runs, user -handlers first. There is no override or shadowing between layers. +## Turning hooks on -**Project hooks are disabled 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. Enable them only for -workspaces you trust, under the `app` section of +**Settings → Agent Hooks**, or directly under the `app` section of `/config/app.json`: ```json @@ -101,24 +70,30 @@ workspaces you trust, under the `app` section of | Setting | Default | Meaning | | --- | --- | --- | | `app.hooks.enabled` | `true` | Master switch. `false` disables all hooks. | -| `app.hooks.project_hooks_enabled` | `false` | Whether `/.bitfun/config/hooks.json` is honored. | +| `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. -### Document schema +Codex's `[features] hooks = false` has no BitFun equivalent — use +`app.hooks.enabled` instead. + +## Quick start + +Create `/config/hooks.json`: ```json { - "description": "optional, free text", "hooks": { - "": [ + "PreToolUse": [ { - "matcher": "optional pattern", + "matcher": "Bash", "hooks": [ { "type": "command", - "command": "the command to run", - "commandWindows": "optional Windows override", - "timeout": 30, - "statusMessage": "optional label" + "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" } ] } @@ -127,191 +102,10 @@ workspaces you trust, under the `app` section of } ``` -The root object accepts only `description` and `hooks`. Any other root key -rejects the whole file — this matches Codex and catches typos early rather than -silently ignoring your configuration. - -Each event maps to a list of **matcher groups**. Each group has an optional -`matcher` and a required `hooks` array of handlers. - -#### Handler fields - -| Field | Required | Meaning | -| --- | --- | --- | -| `type` | yes | Must be `"command"` to execute. `"prompt"` and `"agent"` are accepted but skipped. | -| `command` | yes | Run through `sh -c` (Unix) or `cmd /C` (Windows), with the workspace root as the working directory. | -| `commandWindows` | no | Used instead of `command` on Windows. | -| `timeout` | no | Seconds. Defaults to 600 (`SessionEnd`: 1, capped at 3). | -| `statusMessage` | no | Short label describing what the hook does. | - -#### Matcher semantics - -A matcher filters which handlers run, based on one value per event (see the -event table). Matchers are regular expressions anchored to the whole value, so -a plain name is an exact match. - -| Matcher | Matches | -| --- | --- | -| omitted, `""`, or `"*"` | everything | -| `"Bash"` | exactly `Bash` (not `BashOutput`) | -| `"^Bash$"` | exactly `Bash` | -| `"Edit\|Write"` | `Edit` or `Write` | -| `"mcp__filesystem__.*"` | every tool in that MCP server | -| `"startup"` | that `SessionStart` source (see [Current gaps](#current-gaps)) | - -A matcher that is not a valid pattern (or is not a string) never matches -anything, and is reported in the logs. It is not silently treated as -match-all. - -### Limits - -| Limit | Value | -| --- | --- | -| Maximum size per `hooks.json` | 1 MiB | -| Maximum handlers inspected across all layers | 2048 (invalid and non-`command` handlers count toward it) | -| Maximum model-visible text per hook | 10,000 bytes (truncated with a marker) | - -## Events - -| Event | Fires when | Matcher value | Can block? | -| --- | --- | --- | --- | -| `SessionStart` | a session is created | `source` (currently always `startup`) | no | -| `SessionEnd` | a session is deleted | — | no | -| `UserPromptSubmit` | a prompt is submitted, before the turn starts | — | yes — rejects the prompt | -| `PreToolUse` | a tool call is scheduled, before permission evaluation and before the tool runs | `tool_name` | yes — denies the tool call | -| `PermissionRequest` | a tool call is about to prompt you for permission | `tool_name` | yes — allows or denies instead of prompting | -| `PostToolUse` | a tool call returned a result (successful or error) | `tool_name` | feedback only (appended to the tool result) | -| `PreCompact` | before context compaction | `trigger` (`auto`, `manual`) | no | -| `PostCompact` | after context compaction | `trigger` | no | -| `SubagentStart` | a subagent turn begins | `agent_type` | no | -| `SubagentStop` | a subagent turn settles successfully | `agent_type` | recorded in logs only | -| `Stop` | a top-level turn is about to finish with a final answer | — | yes — reopens the turn (max 3 times) | - -Unknown event names are ignored with a warning; the rest of the file still -loads. - -### Current gaps - -These are real differences from the Codex contract in this release. They -affect what a hook can rely on, so they are listed rather than implied: - -| Field or event | Current behavior | -| --- | --- | -| `transcript_path`, `agent_transcript_path` | always `null` — session transcripts are not exposed to hooks yet | -| `permission_mode` | only `default` or `bypassPermissions`; the other Codex modes never appear | -| `SessionStart.source` | only `startup`; `resume`, `clear`, and `compact` are not dispatched yet | -| `SessionEnd.reason` | always `other` | -| `SubagentStop.stop_hook_active` | always `false` — `SubagentStop` never reopens a subagent turn | -| `SubagentStop` | dispatched when a subagent settles successfully; a failed, cancelled, or timed-out subagent does not dispatch it | -| `Stop` | top-level turns only; subagent turns report through `SubagentStop` | - -## The hook process interface - -### Input: JSON on stdin - -Every payload contains these fields: - -```json -{ - "session_id": "string", - "transcript_path": "string or null", - "cwd": "string", - "hook_event_name": "string", - "model": "string", - "permission_mode": "default | acceptEdits | plan | dontAsk | bypassPermissions" -} -``` - -Every event except `SessionStart` and `SessionEnd` also carries `turn_id`. -Each event then adds its own fields: - -| Event | Additional fields | -| --- | --- | -| `SessionStart` | `source` | -| `SessionEnd` | `reason` | -| `UserPromptSubmit` | `prompt` | -| `PreToolUse` | `tool_name`, `tool_use_id`, `tool_input` | -| `PermissionRequest` | `tool_name`, `tool_input` | -| `PostToolUse` | `tool_name`, `tool_use_id`, `tool_input`, `tool_response` | -| `PreCompact` / `PostCompact` | `trigger` | -| `SubagentStart` | `agent_id`, `agent_type` | -| `SubagentStop` | `agent_id`, `agent_type`, `agent_transcript_path`, `stop_hook_active`, `last_assistant_message` | -| `Stop` | `stop_hook_active`, `last_assistant_message` | - -`stop_hook_active` is `true` when the Agent is already continuing because a -`Stop` hook blocked an earlier attempt to finish. Check it to avoid blocking -forever; BitFun independently caps reopenings at three per turn. - -### Output: exit code, and optional JSON on stdout - -| Exit code | Meaning | -| --- | --- | -| `0` | Success. If stdout parses as JSON, it is read as a decision document (below). | -| `2` | Block the event. **stderr** is the reason shown to the Agent. | -| anything else | Non-blocking error: a warning is logged and the Agent continues. | - -A hook that fails to start, or exceeds its timeout, is also a non-blocking -warning — a broken hook slows the Agent down but never wedges it. - -For `SessionStart`, `UserPromptSubmit`, and `SubagentStart`, plain (non-JSON) -stdout on exit 0 becomes context the model reads. For all other events, plain -stdout is ignored, so `echo` debugging never leaks into the conversation. - -#### Decision document - -All fields are optional: - -```json -{ - "continue": true, - "stopReason": "shown when continue is false", - "systemMessage": "logged for you, not sent to the model", - "suppressOutput": false, - "decision": "block", - "reason": "why the event was blocked", - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow | deny", - "permissionDecisionReason": "string", - "updatedInput": { "command": "the rewritten arguments" }, - "additionalContext": "text the model will read", - "decision": { "behavior": "allow | deny", "message": "string" } - } -} -``` - -Which fields apply per event: - -| Event | Fields it honors | -| --- | --- | -| `PreToolUse` | `permissionDecision` (`allow` skips the permission prompt, `deny` blocks the call), `permissionDecisionReason`, `updatedInput`, and `continue: false` (treated as a denial of that tool call) | -| `PermissionRequest` | `decision.behavior` with `decision.message` | -| `PostToolUse` | `decision: "block"` with `reason`, and `additionalContext` — both appended to the tool result the model reads | -| `UserPromptSubmit` | `decision: "block"` with `reason` (rejects the prompt), `additionalContext`, and `continue: false` (also rejects the prompt) | -| `Stop` / `SubagentStop` | `decision: "block"` with `reason` | -| any | `systemMessage` (written to the BitFun log, never sent to the model) | - -`permissionDecision: "allow"` waives the interactive permission prompt only. -A tool call that a permission rule denies stays denied — a hook can never -widen the permission policy, only narrow it. +Start a new session and ask the Agent to run a shell command; each command it +runs is appended to `~/bitfun-commands.log`. -Two fields are accepted for Codex compatibility but currently have no effect -beyond the rows above: `suppressOutput` is parsed and ignored, and -`continue`/`stopReason` are honored only for `PreToolUse` and -`UserPromptSubmit` (elsewhere, use `decision: "block"` to stop the event). - -When several handlers match one event, they run in order (user layer first) -and their results merge: the first blocking or denying decision stops the -remaining handlers, a `deny` outranks an earlier `allow`, a later -`updatedInput` replaces an earlier one, and context and system messages -accumulate. - -## Examples - -### Block edits to protected paths - -`PreToolUse` returning a deny decision. The Agent is told why, and continues -the turn without the edit. +A hook that blocks — here, refusing edits under `migrations/`: ```json { @@ -331,9 +125,7 @@ the turn without the edit. import json, sys payload = json.load(sys.stdin) -path = payload.get("tool_input", {}).get("file_path", "") - -if "/migrations/" in path: +if "/migrations/" in payload.get("tool_input", {}).get("file_path", ""): print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", @@ -344,114 +136,44 @@ if "/migrations/" in path: sys.exit(0) ``` -### Format and report after every write - -`PostToolUse` returning feedback the model reads. Note that plain stdout and -stderr are ignored for `PostToolUse` — feedback must be `additionalContext` -in a JSON decision document, so the hook is a small script rather than a -one-liner. - -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "bash ~/hooks/format.sh", - "timeout": 60, - "statusMessage": "Formatting Rust code" - } - ] - } - ] - } -} -``` - -```bash -#!/usr/bin/env bash -# ~/hooks/format.sh -cat >/dev/null # drain the payload -output=$(cargo fmt 2>&1 | head -20) -[ -z "$output" ] && exit 0 # nothing to report -jq -n --arg ctx "cargo fmt output:\n$output" \ - '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}' -``` - -### Require a clean test run before finishing - -`Stop` blocking with exit code 2. The stderr text becomes the reason, and the -Agent keeps working instead of finishing. - -```bash -#!/usr/bin/env bash -# ~/hooks/require-tests.sh -payload=$(cat) -if [ "$(printf '%s' "$payload" | jq -r '.stop_hook_active')" = "true" ]; then - exit 0 # already reopened once; don't loop -fi -if ! cargo test --quiet >/tmp/hook-tests.log 2>&1; then - echo "Tests are failing. See /tmp/hook-tests.log, fix them, then finish." >&2 - exit 2 -fi -``` +## Where BitFun differs from Codex -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { "type": "command", "command": "bash ~/hooks/require-tests.sh", "timeout": 300 } - ] - } - ] - } -} -``` +Everything not listed here behaves as the Codex documentation describes. -### Inject project context at session start +### Not supported -Plain stdout from `SessionStart` becomes context the model reads. +| 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 | -```json -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { "type": "command", "command": "git log --oneline -5 && git status --short" } - ] - } - ] - } -} -``` +### Fields not populated yet -### Auto-approve a safe tool without prompting +| 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` | -`PermissionRequest` deciding instead of asking you. +### Behavior worth knowing -```json -{ - "hooks": { - "PermissionRequest": [ - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\",\"message\":\"Reads are always allowed\"}}}'" - } - ] - } - ] - } -} -``` +- **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 @@ -459,14 +181,14 @@ 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 `app.hooks.project_hooks_enabled` off unless you trust everyone who can - commit to the repository, and re-check the project hook file after pulling. +- 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 — the examples above read fields with `jq`/`json.load` for - exactly this reason. -- Hooks inherit BitFun's environment. Do not print secrets to stdout for - context events, where the text is sent to the model. + 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 @@ -476,19 +198,18 @@ every time its event fires. Treat `hooks.json` like a shell profile: | 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. | +| 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. | +| 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 every hook decision are +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. +[`src/crates/LOGGING.md`](../../src/crates/LOGGING.md) for how to raise the +log level. ## Related -- [`/hooks`](../../src/apps/cli/src/modes/chat/external_hooks.rs) in the CLI - inspects hooks configured for *other* AI applications (Claude Code, Codex, - OpenCode). That view is read-only and never executes anything; the hooks - described in this document are BitFun's own and do execute. +- 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 index fb3ef6dd51..033551aad9 100644 --- a/docs/features/agent-hooks.zh-CN.md +++ b/docs/features/agent-hooks.zh-CN.md @@ -5,79 +5,46 @@ Hooks 让你在 BitFun Agent 生命周期的固定节点运行自己的命令: 以及会话与回合的开始与结束。一个 Hook 可以观察 Agent 的行为、注入模型可见的 上下文、改写工具调用参数,或者直接阻止某个动作。 -`hooks.json` 文档结构、事件名、stdin 上的 JSON 载荷、退出码语义以及 stdout 上的 -JSON 决策结构**与 Codex Hooks 一致**,因此 Codex 的 Hook 脚本可以直接在 BitFun -中运行,反之亦然。差异在于文件位置和部分扩展能力: +## BitFun Hooks 就是 Codex Hooks -| | Codex | BitFun | -| --- | --- | --- | -| 用户 Hooks | `~/.codex/hooks.json` | `<用户配置目录>/config/hooks.json` | -| 项目 Hooks | `<仓库>/.codex/hooks.json` | `<工作区>/.bitfun/config/hooks.json` | -| 主配置内联 Hooks | `config.toml` 的 `[hooks]` 表 | 不支持 —— 请使用 `hooks.json` | -| 总开关 | `[features] hooks = false` | `app.json` 中的 `app.hooks.enabled` | -| 插件内置与托管 Hooks | 支持 | 不支持 | - -部分载荷字段目前也尚未填充,见[当前差距](#当前差距)。 - -> 适用范围:本地工作区。远程 SSH 与容器工作区会跳过 Hooks,因为本地 Hook 进程与 -> 远程工作区路径描述的并不是同一个文件系统。 -> -> 非目标:`prompt` 与 `agent` 处理器类型。BitFun 会解析它们(以便共享的配置文件 -> 保持有效),但只执行 `type: "command"` 处理器。 - -## 快速开始 +BitFun 实现的是 **Codex Hook 契约**,不是 BitFun 自己的方言: -1. 在用户配置目录下创建 `hooks.json`: +- 同样的 `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` 等)。 - | 平台 | 路径 | - | --- | --- | - | Linux | `~/.config/bitfun/config/hooks.json` | - | macOS | `~/Library/Application Support/bitfun/config/hooks.json` | - | Windows | `%APPDATA%\bitfun\config\hooks.json` | +**Codex 的 Hook 脚本可以直接在 BitFun 中运行,反之亦然 —— 不需要做任何适配。** -2. 添加一个记录 Agent 执行过的所有 shell 命令的 Hook: +因此本文不重复参考手册。事件语义、各事件的确切载荷字段、决策结构,请直接查阅 +Codex 自己的文档,它把这些写得很完整: - ```json - { - "description": "My hooks", - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" - } - ] - } - ] - } - } - ``` +**→ ** -3. 新建一个 BitFun 会话,让 Agent 执行一条 shell 命令。它执行的每条命令都会追加到 - `~/bitfun-commands.log`。 +本文其余部分只讲 BitFun 特有的内容:文件放在哪、怎么打开、以及目前哪里有差异。 -配置文件变化后会自动重新读取,编辑 `hooks.json` 之后无需重启 BitFun。 +## BitFun 从哪里读取 Hooks -## 配置 +Codex 读 `~/.codex/hooks.json`,BitFun 改为读自己的配置目录。文件内部结构完全相同。 -### 文件位置与分层 +| 层级 | 路径 | +| --- | --- | +| 用户 | `<用户配置目录>/config/hooks.json` | +| 项目 | `<工作区>/.bitfun/config/hooks.json` | -Hooks 按以下顺序从两个层级读取: +用户配置目录在 Linux 为 `~/.config/bitfun`,macOS 为 +`~/Library/Application Support/bitfun`,Windows 为 `%APPDATA%\bitfun`。 -| 顺序 | 层级 | 路径 | -| --- | --- | --- | -| 1 | 用户 | `<用户配置目录>/config/hooks.json`(见上表) | -| 2 | 项目 | `<工作区>/.bitfun/config/hooks.json` | +两个层级是叠加关系:所有匹配的处理器都会执行,用户层优先,层级之间不存在覆盖或 +屏蔽。修改后无需重启 BitFun。 -两个层级是叠加关系:两层中所有匹配的处理器都会执行,用户层优先。层级之间不存在 -覆盖或屏蔽关系。 +## 开启 Hooks -**项目级 Hooks 默认关闭。** 项目 Hook 文件执行的是仓库中的命令,任何能提交代码的 -人都可能借此在你的机器上执行代码。只对你信任的工作区开启,在 -`<用户配置目录>/config/app.json` 的 `app` 段中设置: +**设置 → Agent Hooks**,或直接编辑 `<用户配置目录>/config/app.json` 的 `app` 段: ```json { @@ -93,24 +60,29 @@ Hooks 按以下顺序从两个层级读取: | 配置项 | 默认值 | 含义 | | --- | --- | --- | | `app.hooks.enabled` | `true` | 总开关。`false` 会禁用所有 Hooks。 | -| `app.hooks.project_hooks_enabled` | `false` | 是否启用 `<工作区>/.bitfun/config/hooks.json`。 | +| `app.hooks.project_hooks_enabled` | `false` | 是否启用项目 Hook 文件。 | -### 文档结构 +**项目级 Hooks 默认关闭。** 项目 Hook 文件执行的是仓库中的命令,任何能提交代码的 +人都可能借此在你的机器上执行代码。请只对你信任的仓库开启,并在拉取代码后重新 +检查该文件。 + +Codex 的 `[features] hooks = false` 在 BitFun 没有对应项,请使用 +`app.hooks.enabled`。 + +## 快速开始 + +创建 `<用户配置目录>/config/hooks.json`: ```json { - "description": "可选,自由文本", "hooks": { - "<事件名>": [ + "PreToolUse": [ { - "matcher": "可选的匹配模式", + "matcher": "Bash", "hooks": [ { "type": "command", - "command": "要执行的命令", - "commandWindows": "可选的 Windows 覆盖命令", - "timeout": 30, - "statusMessage": "可选的说明文本" + "command": "jq -r '.tool_input.command' >> ~/bitfun-commands.log" } ] } @@ -119,182 +91,10 @@ Hooks 按以下顺序从两个层级读取: } ``` -根对象只接受 `description` 和 `hooks`。出现其他根级字段会导致整个文件被拒绝 —— 这 -与 Codex 行为一致,可以尽早暴露拼写错误,而不是静默忽略你的配置。 - -每个事件对应一组**匹配组**。每个匹配组包含可选的 `matcher` 和必需的处理器数组 -`hooks`。 - -#### 处理器字段 - -| 字段 | 必需 | 含义 | -| --- | --- | --- | -| `type` | 是 | 必须为 `"command"` 才会执行。`"prompt"` 和 `"agent"` 会被接受但跳过。 | -| `command` | 是 | 通过 `sh -c`(Unix)或 `cmd /C`(Windows)执行,工作目录为工作区根目录。 | -| `commandWindows` | 否 | 在 Windows 上代替 `command` 使用。 | -| `timeout` | 否 | 单位为秒。默认 600(`SessionEnd` 默认 1,上限 3)。 | -| `statusMessage` | 否 | 简短说明该 Hook 的用途。 | - -#### 匹配规则 - -matcher 根据事件对应的一个值筛选要执行的处理器(见事件表)。matcher 是对整个值做 -锚定匹配的正则表达式,因此直接写名字就是精确匹配。 - -| matcher | 匹配范围 | -| --- | --- | -| 省略、`""` 或 `"*"` | 全部匹配 | -| `"Bash"` | 精确匹配 `Bash`(不匹配 `BashOutput`) | -| `"^Bash$"` | 精确匹配 `Bash` | -| `"Edit\|Write"` | `Edit` 或 `Write` | -| `"mcp__filesystem__.*"` | 该 MCP 服务下的所有工具 | -| `"startup"` | 对应的 `SessionStart` 来源(见[当前差距](#当前差距)) | - -无效的 matcher(不是合法模式,或不是字符串)不会匹配任何内容,并会记录到日志。 -它不会被当作"匹配全部"处理。 - -### 限制 - -| 限制项 | 取值 | -| --- | --- | -| 单个 `hooks.json` 最大体积 | 1 MiB | -| 所有层级可检查处理器总数上限 | 2048(无效处理器和非 `command` 处理器同样计入) | -| 单个 Hook 的模型可见文本上限 | 10,000 字节(超出会截断并标记) | +新建一个会话,让 Agent 执行一条 shell 命令,它执行的每条命令都会追加到 +`~/bitfun-commands.log`。 -## 事件 - -| 事件 | 触发时机 | matcher 取值 | 能否阻止? | -| --- | --- | --- | --- | -| `SessionStart` | 创建会话时 | `source`(当前恒为 `startup`) | 否 | -| `SessionEnd` | 删除会话时 | — | 否 | -| `UserPromptSubmit` | 提交提示词后、回合开始前 | — | 可以 —— 拒绝该提示词 | -| `PreToolUse` | 工具调用被排入执行前,早于权限评估与工具运行 | `tool_name` | 可以 —— 拒绝该工具调用 | -| `PermissionRequest` | 工具调用即将向你请求权限时 | `tool_name` | 可以 —— 直接放行或拒绝,不再弹窗 | -| `PostToolUse` | 工具调用返回结果后(成功或报错都会触发) | `tool_name` | 仅反馈(追加到工具结果中) | -| `PreCompact` | 上下文压缩之前 | `trigger`(`auto`、`manual`) | 否 | -| `PostCompact` | 上下文压缩之后 | `trigger` | 否 | -| `SubagentStart` | 子 Agent 回合开始时 | `agent_type` | 否 | -| `SubagentStop` | 子 Agent 回合成功结束时 | `agent_type` | 仅记录到日志 | -| `Stop` | 顶层回合即将以最终回答结束时 | — | 可以 —— 重新打开该回合(最多 3 次) | - -未知事件名会被忽略并记录警告,文件其余部分仍然生效。 - -### 当前差距 - -以下是本版本与 Codex 契约的真实差异。它们会影响 Hook 能依赖的内容,因此明确列出 -而非含糊带过: - -| 字段或事件 | 当前行为 | -| --- | --- | -| `transcript_path`、`agent_transcript_path` | 恒为 `null` —— 会话转录尚未开放给 Hook | -| `permission_mode` | 只会是 `default` 或 `bypassPermissions`,其余 Codex 模式不会出现 | -| `SessionStart.source` | 只有 `startup`;`resume`、`clear`、`compact` 尚未派发 | -| `SessionEnd.reason` | 恒为 `other` | -| `SubagentStop.stop_hook_active` | 恒为 `false` —— `SubagentStop` 不会重新打开子 Agent 回合 | -| `SubagentStop` | 仅在子 Agent 成功结束时派发;失败、取消或超时不会派发 | -| `Stop` | 仅顶层回合触发;子 Agent 回合通过 `SubagentStop` 上报 | - -## Hook 进程接口 - -### 输入:stdin 上的 JSON - -每个载荷都包含以下字段: - -```json -{ - "session_id": "string", - "transcript_path": "string 或 null", - "cwd": "string", - "hook_event_name": "string", - "model": "string", - "permission_mode": "default | acceptEdits | plan | dontAsk | bypassPermissions" -} -``` - -除 `SessionStart` 和 `SessionEnd` 之外的事件还会带上 `turn_id`。各事件另有自己的 -字段: - -| 事件 | 附加字段 | -| --- | --- | -| `SessionStart` | `source` | -| `SessionEnd` | `reason` | -| `UserPromptSubmit` | `prompt` | -| `PreToolUse` | `tool_name`、`tool_use_id`、`tool_input` | -| `PermissionRequest` | `tool_name`、`tool_input` | -| `PostToolUse` | `tool_name`、`tool_use_id`、`tool_input`、`tool_response` | -| `PreCompact` / `PostCompact` | `trigger` | -| `SubagentStart` | `agent_id`、`agent_type` | -| `SubagentStop` | `agent_id`、`agent_type`、`agent_transcript_path`、`stop_hook_active`、`last_assistant_message` | -| `Stop` | `stop_hook_active`、`last_assistant_message` | - -当 Agent 已经因为某个 `Stop` Hook 的阻止而继续运行时,`stop_hook_active` 为 -`true`。检查这个字段可以避免无限阻止;BitFun 本身也会将同一回合的重新打开次数 -限制为 3 次。 - -### 输出:退出码,以及可选的 stdout JSON - -| 退出码 | 含义 | -| --- | --- | -| `0` | 成功。若 stdout 能解析为 JSON,则按决策文档解读(见下)。 | -| `2` | 阻止该事件。**stderr** 的内容作为原因传给 Agent。 | -| 其他 | 非阻塞错误:记录警告,Agent 继续运行。 | - -启动失败或超时的 Hook 同样只产生非阻塞警告 —— 出问题的 Hook 会让 Agent 变慢, -但不会把它卡死。 - -对 `SessionStart`、`UserPromptSubmit`、`SubagentStart` 而言,退出码为 0 时非 JSON -的普通 stdout 会成为模型可见的上下文。其他事件会忽略普通 stdout,因此用 `echo` -调试不会泄漏进对话。 - -#### 决策文档 - -所有字段均为可选: - -```json -{ - "continue": true, - "stopReason": "continue 为 false 时展示", - "systemMessage": "记录给你看,不发送给模型", - "suppressOutput": false, - "decision": "block", - "reason": "被阻止的原因", - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow | deny", - "permissionDecisionReason": "string", - "updatedInput": { "command": "改写后的参数" }, - "additionalContext": "模型会读到的文本", - "decision": { "behavior": "allow | deny", "message": "string" } - } -} -``` - -各事件生效的字段: - -| 事件 | 生效字段 | -| --- | --- | -| `PreToolUse` | `permissionDecision`(`allow` 跳过权限确认,`deny` 阻止调用)、`permissionDecisionReason`、`updatedInput`,以及 `continue: false`(按拒绝该工具调用处理) | -| `PermissionRequest` | `decision.behavior` 及 `decision.message` | -| `PostToolUse` | `decision: "block"` 加 `reason`,以及 `additionalContext` —— 两者都会追加到模型读到的工具结果中 | -| `UserPromptSubmit` | `decision: "block"` 加 `reason`(拒绝该提示词)、`additionalContext`,以及 `continue: false`(同样拒绝该提示词) | -| `Stop` / `SubagentStop` | `decision: "block"` 加 `reason` | -| 任意事件 | `systemMessage`(写入 BitFun 日志,不会发送给模型) | - -`permissionDecision: "allow"` 只会免去交互式权限确认。被权限规则拒绝的工具调用 -依然会被拒绝 —— Hook 只能收紧权限策略,永远无法放宽。 - -以下两个字段为兼容 Codex 而被接受,但当前除上表所列外没有实际作用: -`suppressOutput` 会被解析但忽略;`continue`/`stopReason` 仅对 `PreToolUse` 和 -`UserPromptSubmit` 生效(其他事件请使用 `decision: "block"` 来阻止)。 - -当一个事件匹配到多个处理器时,它们按顺序执行(用户层优先),结果会合并:第一个 -阻止或拒绝的决策会终止其余处理器;`deny` 覆盖此前的 `allow`;后出现的 -`updatedInput` 替换先前的;上下文与系统消息会累加。 - -## 示例 - -### 阻止修改受保护路径 - -`PreToolUse` 返回拒绝决策。Agent 会得知原因,并在没有该编辑的情况下继续回合。 +一个会阻止操作的 Hook —— 这里拒绝修改 `migrations/` 下的文件: ```json { @@ -314,9 +114,7 @@ matcher 根据事件对应的一个值筛选要执行的处理器(见事件表 import json, sys payload = json.load(sys.stdin) -path = payload.get("tool_input", {}).get("file_path", "") - -if "/migrations/" in path: +if "/migrations/" in payload.get("tool_input", {}).get("file_path", ""): print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", @@ -327,112 +125,44 @@ if "/migrations/" in path: sys.exit(0) ``` -### 每次写入后格式化并回报 - -`PostToolUse` 返回模型可读的反馈。注意 `PostToolUse` 会忽略普通 stdout 和 -stderr —— 反馈必须通过 JSON 决策文档中的 `additionalContext` 给出,因此这里用 -一个小脚本而不是一行命令。 +## BitFun 与 Codex 的差异 -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "bash ~/hooks/format.sh", - "timeout": 60, - "statusMessage": "Formatting Rust code" - } - ] - } - ] - } -} -``` - -```bash -#!/usr/bin/env bash -# ~/hooks/format.sh -cat >/dev/null # 读掉载荷 -output=$(cargo fmt 2>&1 | head -20) -[ -z "$output" ] && exit 0 # 没有需要回报的内容 -jq -n --arg ctx "cargo fmt output:\n$output" \ - '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}' -``` - -### 测试通过后才允许结束 - -`Stop` 通过退出码 2 阻止结束,stderr 文本作为原因,Agent 会继续工作而不是收尾。 - -```bash -#!/usr/bin/env bash -# ~/hooks/require-tests.sh -payload=$(cat) -if [ "$(printf '%s' "$payload" | jq -r '.stop_hook_active')" = "true" ]; then - exit 0 # 已经重新打开过一次,不要循环 -fi -if ! cargo test --quiet >/tmp/hook-tests.log 2>&1; then - echo "测试未通过。请查看 /tmp/hook-tests.log,修复后再结束。" >&2 - exit 2 -fi -``` - -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { "type": "command", "command": "bash ~/hooks/require-tests.sh", "timeout": 300 } - ] - } - ] - } -} -``` +未在此列出的部分,行为与 Codex 文档所述一致。 -### 会话开始时注入项目上下文 +### 不支持 -`SessionStart` 的普通 stdout 会成为模型可见的上下文。 +| 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 进程与远程工作区路径描述的不是同一个文件系统 | -```json -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { "type": "command", "command": "git log --oneline -5 && git status --short" } - ] - } - ] - } -} -``` +### 尚未填充的字段 -### 自动放行安全工具,不再弹窗 +| 字段或事件 | 当前行为 | +| --- | --- | +| `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` 上报 | -`PermissionRequest` 代替你做决定。 +### 值得了解的行为 -```json -{ - "hooks": { - "PermissionRequest": [ - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\",\"message\":\"Reads are always allowed\"}}}'" - } - ] - } - ] - } -} -``` +- **Hook 只能收紧权限策略,永远无法放宽。** `PreToolUse` 的 + `permissionDecision: "allow"` 只免去交互式确认;被权限规则拒绝的工具调用依然 + 会被拒绝。 +- `suppressOutput` 会被解析,但当前被忽略。 +- `continue: false` 对 `PreToolUse` 和 `UserPromptSubmit` 生效;其他事件请使用 + `decision: "block"`。 +- `PostToolUse` 在工具返回错误结果时同样会触发,不只是成功时。 +- 限制:单个 `hooks.json` 最大 1 MiB;所有层级最多检查 2048 个处理器(无效处理器 + 和非 `command` 处理器同样计入);单个 Hook 的模型可见文本上限 10,000 字节, + 超出会截断。 ## 安全 @@ -440,12 +170,11 @@ Hook 是以你的用户权限运行的任意代码,且每次对应事件触发 配置文件那样对待 `hooks.json`: - 启用任何非你本人编写的 Hook 之前先审阅它。 -- 除非你信任所有能向仓库提交代码的人,否则保持 `app.hooks.project_hooks_enabled` - 关闭;拉取代码后重新检查项目 Hook 文件。 +- 除非你信任所有能向仓库提交代码的人,否则保持项目级 Hooks 关闭。 - 载荷中的值(提示词、工具参数、文件路径)是模型和用户提供的文本。请按 JSON 解析, 不要拼接进 shell 命令 —— 上面的示例正是为此用 `jq` / `json.load` 读取字段。 -- Hook 继承 BitFun 的环境变量。对于上下文类事件,不要把密钥打印到 stdout,因为这些 - 文本会发送给模型。 +- 不要在 `SessionStart`、`UserPromptSubmit`、`SubagentStart` 中把密钥打印到 + stdout,这些事件的普通 stdout 会成为模型可见的上下文。 ## 排查 @@ -455,16 +184,16 @@ Hook 是以你的用户权限运行的任意代码,且每次对应事件触发 | 项目 Hooks 不运行 | `app.hooks.project_hooks_enabled` 为 `false`(默认值)。 | | 整个文件被忽略 | JSON 无效,或存在 `description`/`hooks` 之外的根级字段。 | | 某个事件被忽略 | 事件名拼写错误 —— 事件名区分大小写。 | -| 某个处理器从不运行 | matcher 不匹配,或 matcher 不是合法模式。 | +| 某个处理器从不运行 | 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 后端日志。提升日志级别的 +配置问题、非零退出、超时以及 Hook 决策都会写入 BitFun 后端日志。提升日志级别的 方法见 [`src/crates/LOGGING.md`](../../src/crates/LOGGING.md)。 ## 相关 -- CLI 中的 [`/hooks`](../../src/apps/cli/src/modes/chat/external_hooks.rs) 用于查看 - *其他* AI 应用(Claude Code、Codex、OpenCode)配置的 Hooks。该视图只读,不会执行 - 任何内容;本文描述的是 BitFun 自身的 Hooks,它们会真正执行。 +- CLI 的 `/hooks` 用于查看*其他* AI 应用(Claude Code、Codex、OpenCode)配置的 + Hooks。该视图只读,不会执行任何内容;本文描述的是 BitFun 自身的 Hooks,它们会 + 真正执行。 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/infrastructure/config/components/HooksConfig.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx new file mode 100644 index 0000000000..7893aa2167 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx @@ -0,0 +1,172 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ExternalLink } from 'lucide-react'; +import { Button, ConfigPageLoading, Switch } from '@/component-library'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { configManager } from '../services/ConfigManager'; +import { + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageRow, + ConfigPageSection, +} from './common'; + +const log = createLogger('HooksConfig'); + +const CODEX_HOOKS_DOC_URL = 'https://learn.chatgpt.com/docs/hooks'; + +/** Enablement gates only. Hook declarations live in hooks.json. */ +interface AgentHooksConfigShape { + enabled: boolean; + project_hooks_enabled: boolean; +} + +const DEFAULT_HOOKS_CONFIG: AgentHooksConfigShape = { + enabled: true, + project_hooks_enabled: false, +}; + +function normalizeHooksConfig( + config: Partial | 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 設定失敗" + } +} From 42643c06d5d940c82384ed879605b7de9f35ac5d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 10:32:56 -0700 Subject: [PATCH 5/5] fix(hooks): register the Hooks tab in the settings search content map SETTINGS_TAB_SEARCH_CONTENT is a Record, so adding a tab without a matching entry fails the type-check. Give the Hooks tab its section titles and descriptions so settings search matches its content. --- .../app/scenes/settings/settingsTabSearchContent.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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