fix(hooks): reject invalid matcher regexes

This commit is contained in:
ZiyaZhang
2026-07-28 02:43:51 -07:00
parent f63d2deecf
commit 3f71f91d5b
21 changed files with 143 additions and 45 deletions

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-claude/README.md
README.md: 24259c24ea35cd450f8ea27ca2cca423ed4406bd
README.zh.md: 9f58b782190721de08750e5bd4eac9e5effd5c6a
README.md: 8bdce8555b4b1919bdeebf02cbf35f7c60a3e1ff
README.zh.md: 4cceffb364b80b61686561a89b0b2a0161d16566

View File

@@ -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 — 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, 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.

View File

@@ -28,7 +28,7 @@ const config: Config = {
projectDir: .
```
配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳:桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 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 正则(报告其 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 项目树,而非服务器启动目录。

View File

@@ -6,7 +6,7 @@
* @module @deepseek-ai/dsh-hooks-claude/config
*/
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
/** A parsed CC config: event name → its matcher groups (command hooks only). */
export type ClaudeHookConfig = Record<string, MatcherGroup[]>
@@ -54,7 +54,8 @@ 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; non-command hooks are returned in `skipped`, and substitutions
* are applied to every surviving command.
* are applied to every surviving command. A runnable group with an invalid regex matcher 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.
@@ -92,8 +93,11 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
})
}
if (commands.length === 0) continue
const matcher = 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({
...typeof group.matcher === 'string' ? { matcher: group.matcher } : {},
...matcher !== undefined ? { matcher } : {},
hooks: commands,
})
}

View File

@@ -44,17 +44,22 @@ function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): stri
return dir
}
async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
return (await harnessWithFiber(configDir, adapter)).ctx
async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
return (await harnessWithFiber(configDir, adapter, beforeHooks)).ctx
}
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> {
async function harnessWithFiber(
configDir: string,
adapter: MockAdapter,
beforeHooks?: (ctx: Context) => void,
): Promise<{ ctx: Context; hooks: Fiber }> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
beforeHooks?.(ctx)
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, hooks }
@@ -360,6 +365,25 @@ describe('hooks-claude bridge — load resilience', () => {
expect(adapter.requests).toHaveLength(1)
})
it('an invalid regex matcher is reported and registers no hooks', async () => {
const dir = writeConfig({
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 2' }] }],
})
const adapter = new MockAdapter([textResponse('fine')])
const warn = vi.fn()
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'invalid claude regex matcher "(" on event "PreToolUse"',
))
})
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
// would veto the prompt (0 model requests) and log a hook/invoked. Build the

View File

@@ -63,4 +63,10 @@ describe('parseClaudeConfig', () => {
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] })
expect('matcher' in config.Stop![0]!).toBe(false)
})
it('rejects an invalid regex matcher with its event name', () => {
expect(() => parseClaudeConfig({
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }],
})).toThrow('invalid claude regex matcher "(" on event "PreToolUse"')
})
})