Browse Source

fix(hooks): ignore matcherless event fields

ZiyaZhang 2 tháng trước cách đây
mục cha
commit
55321fe7a4

+ 2 - 2
.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: 11986c01f76c7b3cc7eb5ebe9627dc0ded8afd95
-2026-06-30-hook-protocol-lib.zh.md: 6abccabf517b69b642cd5db52281e4e8a8d526c7
+2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857
+2026-06-30-hook-protocol-lib.zh.md: 354edd9d03b49cf43b6ad500108e741787308ec6

+ 1 - 1
.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, validates runnable groups for supported events, 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)`. 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.
 - **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.

+ 1 - 1
.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 前忽略不支持的事件,仅校验受支持事件中可运行的 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。
+- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 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 按序累积。

+ 3 - 2
examples/acp-agent/tests/acp.snapshot.ts

@@ -212,8 +212,9 @@ const SCENARIOS: Scenario[] = [
     headerClass: 'advanced',
     configPath: ADVANCED_CONFIG,
   },
-  // Prompt-submit blocks are authored keylessly. Admission rejects before a
-  // turn opens, so only the ACP stop reason is observable and no log is harvested.
+  // Prompt-submit blocks are authored keylessly with malformed matcher fields,
+  // which these matcherless events must ignore. Admission rejects before a turn
+  // opens, so only the ACP stop reason is observable and no log is harvested.
   { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
   { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
   // Each invalid matcher follows a runnable prompt blocker. Reaching the replay

+ 1 - 0
examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json

@@ -2,6 +2,7 @@
   "hooks": {
     "UserPromptSubmit": [
       {
+        "matcher": "[",
         "hooks": [
           { "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" }
         ]

+ 1 - 0
examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json

@@ -2,6 +2,7 @@
   "hooks": {
     "UserPromptSubmit": [
       {
+        "matcher": "[",
         "hooks": [
           { "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" }
         ]

+ 2 - 2
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: 92b5e146c7da3531246da627884143991c76932b
-README.zh.md: c49b7e75848f9bc736492b66d1126aa93287ace4
+README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805
+README.zh.md: 10fde6ec2fd0e6803bc91324fd11a9f9a1438db4

+ 1 - 1
packages/hooks/hook-protocol/README.md

@@ -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 use `matcherDiagnostic` to reject an invalid 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/`''`/`'*'`; `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.
 - **`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.

+ 1 - 1
packages/hooks/hook-protocol/README.zh.md

@@ -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` 模式始终使用未锚定正则。桥接解析器使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。
+- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器会丢弃没有 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` 按顺序累积。

+ 3 - 1
packages/hooks/hook-protocol/src/matcher.ts

@@ -21,7 +21,9 @@ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
 function compileRegex(pattern: string): RegExp | undefined {
   try {
     return new RegExp(pattern)
-  } catch {
+  } catch (_syntaxError) {
+    // RegExp construction is the try's only operation, so malformed pattern
+    // syntax is the only expected failure.
     return undefined
   }
 }

+ 2 - 2
packages/hooks/hooks-claude/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-claude/README.md
-README.md: 413159759dc76478beeb65c8e380df77c0a26e86
-README.zh.md: 43c0b4644891a14e832ef35db7ffe11f11a4e545
+README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa
+README.zh.md: 4eae4072ec5054eaa9b1be3deb2074903bea3773

+ 1 - 1
packages/hooks/hooks-claude/README.md

@@ -28,7 +28,7 @@ In a `cordis.yml`:
     projectDir: .
 ```
 
-The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
+The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher on an event that consumes matchers, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
 
 The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
 

+ 1 - 1
packages/hooks/hooks-claude/README.zh.md

@@ -28,7 +28,7 @@ const config: Config = {
     projectDir: .
 ```
 
-配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳,其中包括无效 matcher 正则(报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent 停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。
+配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳,其中包括实际消费 matcher 的事件所带的无效 matcher 正则(报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent 停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。
 
 hook **本身** 会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd`(`session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`/相对路径/marker 作用于 user 项目树,而非服务器启动目录。
 

+ 6 - 3
packages/hooks/hooks-claude/src/config.ts

@@ -65,8 +65,9 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
  * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
  * ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
  * non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
- * command. A supported runnable group with an invalid regex matcher throws a `SyntaxError`, allowing
- * the bridge to reject the complete config before listener registration.
+ * command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
+ * matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
+ * `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
  *
  * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
  *   event map.
@@ -105,7 +106,9 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
         })
       }
       if (commands.length === 0) continue
-      const matcher = typeof group.matcher === 'string' ? group.matcher : undefined
+      const matcher = event === 'UserPromptSubmit' || event === 'Stop'
+        ? undefined
+        : typeof group.matcher === 'string' ? group.matcher : undefined
       const diagnostic = matcherDiagnostic(matcher, 'claude')
       if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
       groups.push({

+ 3 - 2
packages/hooks/hooks-claude/tests/bridge.spec.ts

@@ -90,13 +90,14 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
 
 describe('hooks-claude bridge — UserPromptSubmit', () => {
   it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
-    // The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
+    // UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks
+    // with the reason on stderr.
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
     dirs.push(dir)
     const block = join(dir, 'block.sh')
     writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
     chmodSync(block, 0o755)
-    writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } }))
+    writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } }))
 
     const adapter = new MockAdapter([textResponse('should not run')])
     const ctx = await harness(dir, adapter)

+ 12 - 0
packages/hooks/hooks-claude/tests/config.spec.ts

@@ -70,6 +70,18 @@ describe('parseClaudeConfig', () => {
     })).toThrow('invalid claude regex matcher "(" on event "PreToolUse"')
   })
 
+  it('discards matcher fields on events without matcher subjects before validation', () => {
+    const { config } = parseClaudeConfig({
+      UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
+      Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
+    })
+
+    expect(config).toEqual({
+      UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }],
+      Stop: [{ hooks: [{ command: 'stop.sh' }] }],
+    })
+  })
+
   it('ignores invalid matchers on unsupported events without dropping supported hooks', () => {
     const { config } = parseClaudeConfig({
       Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }],

+ 2 - 2
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: 62a9599b17a816205f91cee8ba6ce7eab1852681
-README.zh.md: 813c8ca1def904c3f6b79472ecc785ff316606d6
+README.md: e906810ed58c3d0204c618c32787af06c91cfb78
+README.zh.md: 8992cc63edf74d057114d888c396881dc8ee43d6

+ 1 - 1
packages/hooks/hooks-codex/README.md

@@ -34,7 +34,7 @@ In a `cordis.yml`:
     model: deepseek-v4
 ```
 
-The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
+The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
 
 The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
 

+ 1 - 1
packages/hooks/hooks-codex/README.zh.md

@@ -34,7 +34,7 @@ const config: Config = {
     model: deepseek-v4
 ```
 
-配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容);无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
+配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
 
 hook 本身会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于 user 项目树,而非服务器启动目录。
 

+ 7 - 4
packages/hooks/hooks-codex/src/config.ts

@@ -33,9 +33,10 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
 
 /**
  * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
- * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. A runnable group
- * with an invalid regex matcher throws a `SyntaxError`, allowing the bridge to reject the complete
- * config before listener registration.
+ * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
+ * UserPromptSubmit and Stop are discarded because those events have no matcher subject. A
+ * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
+ * to reject the complete config before listener registration.
  * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
  * @returns the runnable per-event groups plus the skipped hooks with their reasons.
  */
@@ -71,7 +72,9 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
         commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} })
       }
       if (commands.length === 0) continue
-      const matcher = typeof group.matcher === 'string' ? group.matcher : undefined
+      const matcher = event === 'UserPromptSubmit' || event === 'Stop'
+        ? undefined
+        : typeof group.matcher === 'string' ? group.matcher : undefined
       const diagnostic = matcherDiagnostic(matcher, 'codex')
       if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
       groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands })

+ 5 - 5
packages/hooks/hooks-codex/tests/bridge.spec.ts

@@ -89,11 +89,11 @@ describe('hooks-codex bridge', () => {
 
   it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
     const dir = configDir()
-    // Block once with a marker; until the loop guard lands, an always-blocking
-    // hook would never let this test finish.
+    // Stop ignores its malformed matcher field. Block once with a marker;
+    // until the loop guard lands, an always-blocking hook would never finish.
     const marker = join(dir, 'fired')
     const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
-    writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
+    writeHooks(dir, { Stop: [{ matcher: '[', hooks: [{ type: 'command', command: cont }] }] })
 
     const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
     const ctx = await harness(dir, adapter)
@@ -156,7 +156,7 @@ describe('hooks-codex bridge', () => {
     const dir = configDir()
     writeHooks(dir, {
       UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
-      Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }],
+      PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }],
     })
     const adapter = new MockAdapter([textResponse('ok')])
     const warn = vi.fn()
@@ -168,7 +168,7 @@ describe('hooks-codex bridge', () => {
     expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
 
     expect(warn).toHaveBeenCalledWith(expect.stringContaining(
-      'invalid codex regex matcher "[" on event "Stop"',
+      'invalid codex regex matcher "[" on event "PreToolUse"',
     ))
   })
 

+ 14 - 2
packages/hooks/hooks-codex/tests/config.spec.ts

@@ -68,7 +68,19 @@ describe('parseCodexConfig', () => {
 
   it('rejects an invalid regex matcher with its event name', () => {
     expect(() => parseCodexConfig({
-      Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }],
-    })).toThrow('invalid codex regex matcher "[" on event "Stop"')
+      PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }],
+    })).toThrow('invalid codex regex matcher "[" 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' }] }],
+      Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
+    })
+
+    expect(config).toEqual({
+      UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }],
+      Stop: [{ hooks: [{ command: 'stop.sh' }] }],
+    })
   })
 })