fix(hooks): match Codex Rust regex syntax
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 按顺序累积。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user