fix(hooks): match Codex Rust regex syntax

This commit is contained in:
ZiyaZhang
2026-07-28 10:37:53 -07:00
parent 140a1eaccc
commit 0ef2327e34
21 changed files with 115 additions and 62 deletions
@@ -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
@@ -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.
@@ -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 解析 stdoutexit `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 按序累积。
+1 -1
View File
@@ -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`
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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)。
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -10,7 +10,7 @@ Claude CodeCodex 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 CodeCodex 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 使用 JavaScriptCodex 使用 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` 按顺序累积。
@@ -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",
+47 -23
View File
@@ -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)
}
}
+4 -4
View File
@@ -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'
@@ -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)"')
})
})
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -7,7 +7,7 @@
该桥接实现 Codex 当前 hook 协议的一个明确子集:
- **10 个 hook 点中的 5 个:** `PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`
- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。
- **原生 Codex matcher 语义:**纯 wordpipe pattern 是精确匹配的多选;其他 pattern 是未锚定的 Rust `regex` 表达式(包括 `(?i)` 等内联 flag)。
- **snake_case stdin payload**,携带 `turn_id``model` 额外字段,写入时**不带** 尾随换行符。
- **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。
- **没有工具前批准或改写路径**:hook 可以阻塞,但桥接不会预批准或替换工具输入。
+6 -5
View File
@@ -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)
@@ -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)
@@ -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' }] }],
+9
View File
@@ -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: