From 0ef2327e3464214da482af024995c567234dfd7d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:37:53 -0700 Subject: [PATCH] fix(hooks): match Codex Rust regex syntax --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- docs/config-catalog.md | 2 +- packages/hooks/README.i18n.yaml | 6 +- packages/hooks/README.md | 2 +- packages/hooks/README.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/package.json | 3 + packages/hooks/hook-protocol/src/matcher.ts | 70 +++++++++++++------ packages/hooks/hook-protocol/src/types.ts | 8 +-- .../hooks/hook-protocol/tests/matcher.spec.ts | 18 +++-- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/index.ts | 11 +-- .../hooks/hooks-codex/tests/bridge.spec.ts | 6 +- .../hooks/hooks-codex/tests/config.spec.ts | 12 +++- pnpm-lock.yaml | 9 +++ 21 files changed, 115 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 47ac4bf302..24770df7cb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md -2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 -2026-06-30-hook-protocol-lib.zh.md: 354edd9d03b49cf43b6ad500108e741787308ec6 +2026-06-30-hook-protocol-lib.md: 37f379a199b6e613101f76ac1700671eae1915b8 +2026-06-30-hook-protocol-lib.zh.md: 4f03ce6d9c33c16c9a12dc3dbe2a673a31eb0d85 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index ce25f40e96..37f379a199 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -15,7 +15,7 @@ This Agent Note introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs. **Shared (here):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop. +- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop. - **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). - **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 354edd9d03..4f03ce6d9c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 - **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14946b9f0f..ac34b8faa0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -482,7 +482,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index af9d4aa4a8..b3dc73cce7 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a -README.zh.md: 21c75f0476c76c0be75dc3af25ffb9a2be28dc4e +# pnpm run verify-translation-pairing --write packages/hooks/README.md +README.md: 9084f93e6b76e366f81986a052eb35e3811a0c13 +README.zh.md: 889538080cf12fc363838338b87cc0bc06127c4f diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 23478fb5e9..9084f93e6b 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -10,4 +10,4 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau | `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin | | `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin | -Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). +Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, a Rust-regex matcher dialect, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index 21c75f0476..889538080c 100644 --- a/packages/hooks/README.zh.md +++ b/packages/hooks/README.zh.md @@ -10,4 +10,4 @@ hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent | `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 | | `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 | -Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 形状相同、5 个事件而非 CC 的众多事件、仅命令、仅正则表达式 matcher、没有 env/替换),因此 `hook-protocol` 拥有真正相同的原语,每个桥接只拥有不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 +Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 形状相同、5 个事件而非 CC 的众多事件、仅命令、使用 Rust 正则 matcher 方言、没有 env/替换),因此 `hook-protocol` 拥有真正相同的原语,每个桥接只拥有不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 231aab555d..de3ac9a7a6 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md -README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 -README.zh.md: 10fde6ec2fd0e6803bc91324fd11a9f9a1438db4 +README.md: e8e3e1b078f74636ee23f90a96d1e8748d7af373 +README.zh.md: 4671b179b222eea68cbcb00042d2ddcfe9a5691f diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 8cf4b95c95..e8e3e1b078 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud | Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic | +| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`) and rejects a config group carrying a diagnostic | | Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | | Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision | | Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — | @@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud ## Primitives -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop. +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop. - **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 10fde6ec2f..4671b179b2 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),并拒绝带有诊断的配置组 | | 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** | | 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision | | 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) | @@ -19,7 +19,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 - **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-bash` 受信任插件表层),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。 - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码 2 使用 stderr 阻塞;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。 - **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,首个 `continue:false` 使 halt 粘滞,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index f357278db3..5ae4b98169 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -26,6 +26,9 @@ "src" ], "license": "BSD-3-Clause", + "dependencies": { + "rregex": "1.12.0" + }, "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 9c5606a975..e851a62f2e 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,56 +1,74 @@ /** * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ - * pipe patterns as literal alternatives and other patterns as regex; Codex - * treats every non-empty pattern as an unanchored regex. Missing, empty, and - * `*` match all. Runtime matching contains invalid regexes as non-matches; - * config parsers use {@link matcherDiagnostic} to reject them with a diagnostic. + * pipe patterns as literal alternatives and other patterns as regex. Codex + * uses the same literal fast path, then compiles regex patterns with Rust's + * `regex` dialect. Missing, empty, and `*` match all. Runtime matching contains + * invalid regexes as non-matches; config parsers use {@link matcherDiagnostic} + * to reject them with a diagnostic. * @module @deepseek-ai/dsh-hook-protocol/matcher */ +import { createRequire } from 'node:module' +import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' +// rregex's ESM entry initializes WASM with top-level await. Hook plugins are +// discovered through Cordis Loader's synchronous module boundary, so use the +// package's equivalent synchronous Node entry rather than making both bridge +// modules async merely by importing this shared matcher. +const { RRegex } = createRequire(import.meta.url)('rregex') as { + RRegex: new(pattern: string) => RustRegex +} + /** True for an absent / empty / `'*'` pattern — the match-all sentinels. */ function isMatchAll(matcher: string | undefined): boolean { return matcher === undefined || matcher === '' || matcher === '*' } -/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ -const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ +/** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ +const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ -/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */ -function compileRegex(pattern: string): RegExp | undefined { +/** Compile one dialect's unanchored regex; invalid patterns return `undefined`. */ +function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | undefined { try { - return new RegExp(pattern) + return mode === 'codex' ? new RRegex(pattern) : new RegExp(pattern) } catch (_syntaxError) { - // RegExp construction is the try's only operation, so malformed pattern - // syntax is the only expected failure. + // Regex construction is the try's only operation, so malformed syntax in + // the selected dialect is the only expected failure. return undefined } } +/** Release the WASM-backed Codex regex once a one-shot validation or match is done. */ +function disposeRegex(regex: RegExp | RustRegex): void { + if (regex instanceof RRegex) regex.free() +} + /** * Validate one matcher before a bridge accepts its config group. * @param matcher - configured pattern; match-all sentinels are valid. - * @param mode - dialect deciding whether a word-and-pipe pattern is literal. + * @param mode - dialect deciding which regex engine validates non-literal patterns. * @returns `undefined` for a valid matcher, otherwise a stable diagnostic. */ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined { if (isMatchAll(matcher)) return undefined const pattern = matcher as string - if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined - return compileRegex(pattern) === undefined - ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` - : undefined + if (EXACT_MATCHER.test(pattern)) return undefined + const regex = compileRegex(pattern, mode) + if (regex === undefined) return `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` + disposeRegex(regex) + return undefined } /** - * Whether `matcher` selects `query` under the given dialect. Claude literal - * patterns exact-match pipe-separated alternatives; all other patterns are - * unanchored regexes. Invalid regexes return `false` rather than throwing; - * bridge config parsers surface them through {@link matcherDiagnostic} before use. + * Whether `matcher` selects `query` under the given dialect. Literal patterns + * exact-match pipe-separated alternatives; all other patterns are unanchored + * regexes in the selected dialect. Invalid regexes return `false` rather than + * throwing; bridge config parsers surface them through {@link matcherDiagnostic} + * before use. * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. * @param query - the candidate value (a tool name, a session source, …). - * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. + * @param mode - the dialect deciding which regex engine matches the pattern. * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid * regex. */ @@ -58,8 +76,14 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode: if (isMatchAll(matcher)) return true // matcher is a non-empty string past the match-all guard. const pattern = matcher as string - if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { + if (EXACT_MATCHER.test(pattern)) { return pattern.split('|').includes(query) } - return compileRegex(pattern)?.test(query) ?? false + const regex = compileRegex(pattern, mode) + if (regex === undefined) return false + try { + return regex instanceof RRegex ? regex.isMatch(query) : regex.test(query) + } finally { + disposeRegex(regex) + } } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index e14473b3e1..0ff6d4dc48 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -71,10 +71,10 @@ export interface MatcherGroup { } /** - * How a matcher pattern is interpreted. Claude Code uses {@link literal} when the - * pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and - * {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the - * mode for its dialect. + * How a matcher pattern is interpreted. Both dialects use an exact-match fast + * path when the pattern is purely `[A-Za-z0-9_|]+` (pipe = alternation), then + * use their native regex dialect otherwise: JavaScript for Claude Code and Rust + * `regex` for Codex. The bridge picks the mode for its dialect. */ export type MatcherMode = 'claude' | 'codex' diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index a1f794aa28..7050ab260d 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -34,11 +34,10 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => { }) }) -describe('matchesMatcher — codex dialect (always regex)', () => { - it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => { +describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => { + it('a word pattern uses Codex exact-match semantics', () => { expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true) - // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring - expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true) + expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false) }) it('regex alternation and anchors work', () => { @@ -46,6 +45,14 @@ describe('matchesMatcher — codex dialect (always regex)', () => { expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true) expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false) }) + + it('uses Rust regex syntax and matching semantics', () => { + expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true) + expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true) + expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true) + // JavaScript accepts look-around, but Rust regex deliberately does not. + expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false) + }) }) describe('matchesMatcher — invalid regex is a non-match (never throws)', () => { @@ -65,10 +72,13 @@ describe('matcherDiagnostic — parse-time diagnostics', () => { expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined() expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined() expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined() + expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined() + expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', 'codex')).toBeUndefined() }) it('returns a stable diagnostic for invalid regexes in either dialect', () => { expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("') expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["') + expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"') }) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index a9c4b33562..20d5781678 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/hooks-codex/README.md -README.md: e906810ed58c3d0204c618c32787af06c91cfb78 -README.zh.md: 8992cc63edf74d057114d888c396881dc8ee43d6 +README.md: 0c9a6b22d0990d87ad081db4f2690c5d97357062 +README.zh.md: d3c88a75208257585255fc36ad6cc0a7a3b5c0f0 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index e906810ed5..0c9a6b22d0 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -7,7 +7,7 @@ A cordis plugin that runs the supported subset of a user's existing **Codex** ho This bridge implements a deliberate subset of Codex's current hook protocol: - **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. -- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex). +- **Native Codex matcher semantics:** pure word/pipe patterns are exact alternatives; other patterns are unanchored Rust `regex` expressions (including inline flags such as `(?i)`). - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline. - **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell). - **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 8992cc63ed..d3c88a7520 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -7,7 +7,7 @@ 该桥接实现 Codex 当前 hook 协议的一个明确子集: - **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。 -- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。 +- **原生 Codex matcher 语义:**纯 word/pipe pattern 是精确匹配的多选;其他 pattern 是未锚定的 Rust `regex` 表达式(包括 `(?i)` 等内联 flag)。 - **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带** 尾随换行符。 - **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。 - **没有工具前批准或改写路径**:hook 可以阻塞,但桥接不会预批准或替换工具输入。 diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index d68e2b9d0a..0c1ce5f2db 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,9 +1,10 @@ /** * Bridge for unmodified Codex command hooks on harness interception seams. It - * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only - * matchers, snake_case payloads without a trailing newline, no hook environment - * or command substitution, and no pre-tool approval or rewrite path; only - * blocking decisions are honored. Shared execution and parsing live in + * supports five points (SessionStart, prompt/tool pre/post, Stop), native + * literal-or-Rust-regex matchers, snake_case payloads without a trailing + * newline, no hook environment or command substitution, and no pre-tool + * approval or rewrite path; only blocking decisions are honored. Shared + * execution and parsing live in * `dsh-hook-protocol`; see the * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-codex @@ -127,7 +128,7 @@ export function apply(ctx: Context, config: Config): void { // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd for (const group of groups) { - // Codex always interprets matchers as regexes; it has no literal fast path. + // The protocol library owns Codex's exact-literal/Rust-regex split. if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 3e9ae5617a..91f30e33d4 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -66,11 +66,11 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-codex bridge', () => { - it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => { + it('a PreToolUse hook (exit 2) honors a Rust-regex inline flag matcher', async () => { const dir = configDir() const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n') - // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash". - writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] }) + // `(?i)` is accepted by Rust regex but rejected by JavaScript RegExp. + writeHooks(dir, { PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(dir, adapter) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 8503d13151..e15adf9e45 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -61,9 +61,9 @@ describe('parseCodexConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) - it('keeps a matcher when present', () => { - const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) - expect(config.PreToolUse![0]!.matcher).toBe('^Bash$') + it('keeps a valid Rust-regex matcher when present', () => { + const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) + expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$') }) it('rejects an invalid regex matcher with its event name', () => { @@ -72,6 +72,12 @@ describe('parseCodexConfig', () => { })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') }) + it('rejects JavaScript-only regex syntax that Codex cannot execute', () => { + expect(() => parseCodexConfig({ + PreToolUse: [{ matcher: '(?=Bash)', hooks: [{ type: 'command', command: 's.sh' }] }], + })).toThrow('invalid codex regex matcher "(?=Bash)" on event "PreToolUse"') + }) + it('discards matcher fields on events without matcher subjects before validation', () => { const { config } = parseCodexConfig({ UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 033f120c03..b357bf4b87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2593,6 +2593,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hook-protocol: + dependencies: + rregex: + specifier: 1.12.0 + version: 1.12.0 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -9977,6 +9981,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rregex@1.12.0: + resolution: {integrity: sha512-lMRD7lU4TYrAyhrN6/3PXp6wiOtbsdVuHD9JtNsFCW7ZsRaOWQ2vVB41whpU1jWny1JTTS6aRnnkdSOUMdwFKQ==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -15376,6 +15383,8 @@ snapshots: transitivePeerDependencies: - supports-color + rregex@1.12.0: {} + rw@1.3.3: {} sade@1.8.1: