docs: audit hook compatibility limits
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
# @deepseek-ai/dsh-hooks-codex
|
||||
|
||||
A cordis plugin that runs a user's existing **Codex** `hooks.json` on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-specific payloads, matcher mode, and decision mapping.
|
||||
A cordis plugin that runs the supported subset of a user's existing **Codex** hook config on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-shaped payloads, matcher mode, and decision mapping.
|
||||
|
||||
Codex's hook protocol is a deliberate **subset** of Claude Code's (same `hooks.json` shape):
|
||||
This bridge implements a deliberate subset of Codex's current hook protocol:
|
||||
|
||||
- **Five hook points only:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent / notification / compaction hooks.
|
||||
- **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).
|
||||
- **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline.
|
||||
- **No env vars and no command substitution** (a literal `${…}` in a command survives verbatim).
|
||||
- **A block-only decision model** — `allow`/`ask` are not honored; a hook can only block, never pre-approve.
|
||||
- **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.
|
||||
|
||||
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
|
||||
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -32,7 +32,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). 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 Codex 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). 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.
|
||||
|
||||
@@ -70,8 +70,11 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }`
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Stop loop-guard** (`TODO(stop-loop-guard)`) — as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred, and a hook author must self-limit until it lands.
|
||||
- **`systemMessage`** — a hook's user-facing warning is logged + warned, not surfaced; there is no user-message channel on these seams yet (only model-facing `additionalContext`).
|
||||
- **`{"continue": false}` is recorded, not enforced** — the `hook/result` event records decision `stop`, but the run is not halted (`TODO(hook-continue-false)`).
|
||||
- **`SessionStart` cannot gate the first turn** — `agent/session-start` is a synchronous emit with a detached continuation, so a hook's injected context lands best-effort before turn 1 (`TODO(session-start-gating)`).
|
||||
- **Hook config is process-level** — one `configPath` parsed at load for the whole process; per-session discovery of a project-local config is deferred (`TODO(per-session-hook-config)`).
|
||||
- **Unsupported hook events (5 of Codex's current 10):** `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, and `SubagentStop`. Config for these events is silently dropped during parsing. The comparison baseline is Codex's [official hook reference](https://learn.chatgpt.com/docs/hooks).
|
||||
- **`SessionStart` is partial:** plain stdout and JSON `additionalContext` work, but the hook runs detached, so context can miss the first request (`TODO(session-start-gating)`).
|
||||
- **`UserPromptSubmit` is partial:** blocking plus plain-stdout or JSON context work, but the common `systemMessage` and `{"continue": false}` controls are not enforced.
|
||||
- **`PreToolUse` is partial:** blocking works, but `additionalContext`, `permissionDecision: "allow"`, and `updatedInput` are ignored. Every tool is represented as `tool_input: { command }`, so non-shell tool arguments are not faithfully exposed to the hook.
|
||||
- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `{"continue": false}` is not enforced, non-shell tool arguments are reduced to `{ command }`, and structured tool output is flattened to text in `tool_response`.
|
||||
- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message` is always `null`, and `{"continue": false}` is not enforced. An unconditionally blocking hook therefore force-continues every step unless it self-limits (`TODO(stop-loop-guard)`).
|
||||
- **Common payload and output fields are partial:** every mapped event reports `transcript_path: null`, the statically configured `model`, and `permission_mode: "default"` instead of current Codex runtime values. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior (`TODO(hook-continue-false)`).
|
||||
- **Config loading and execution are partial:** one process-level `configPath` is parsed at load; Codex's active user, project, session, system/managed, and plugin layers, trust controls, and inline `config.toml` hook form are not implemented (`TODO(per-session-hook-config)`). Only synchronous `command` handlers run, current metadata such as `statusMessage` and `commandWindows` is ignored, and matching handlers run serially rather than with Codex's concurrent launch semantics.
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. Codex's
|
||||
* config format is a SUBSET of Claude Code's: the same event-name → matcher-group
|
||||
* structure and the same `{ type: 'command', command, timeout?/timeoutSec? }`
|
||||
* hook shape, but only five events and NO command-string substitution (Codex sets
|
||||
* no hook env vars and does not expand `${…}`). Non-command hooks (and Codex's
|
||||
* `async: true` commands) are parsed-and-skipped with a warning.
|
||||
* Parse the bridge-supported subset of a Codex `hooks.json` into the shared
|
||||
* {@link MatcherGroup} shape. The bridge accepts five events and the
|
||||
* `{ type: 'command', command, timeout?/timeoutSec? }` hook shape, performs no
|
||||
* config-time placeholder substitution or plugin-env injection, and skips
|
||||
* non-command and `async: true` handlers with a warning.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-hooks-codex/config
|
||||
*/
|
||||
|
||||
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** The five hook points Codex's engine supports. */
|
||||
/** The five current Codex hook points this bridge supports. */
|
||||
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
|
||||
|
||||
/** A parsed Codex config: event name → its matcher groups (command hooks only). */
|
||||
@@ -37,10 +36,10 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
/**
|
||||
* Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s.
|
||||
* Only the five {@link CODEX_EVENTS} are honored; an unknown event is dropped.
|
||||
* Only the five bridge-supported {@link CODEX_EVENTS} are honored; another event is dropped.
|
||||
* `type !== 'command'` and `async: true` command hooks are skipped (recorded in
|
||||
* `skipped`). Malformed entries are ignored rather than thrown — a bad config
|
||||
* must not crash boot. No command substitution (Codex does none).
|
||||
* must not crash boot. No config-time placeholder substitution is performed.
|
||||
* @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.
|
||||
*/
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
* `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT
|
||||
* half of the hooks subsystem.
|
||||
*
|
||||
* Codex's hook protocol is a deliberate SUBSET of Claude Code's: five hook points
|
||||
* (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no
|
||||
* subagent/notification/compaction), regex-only matchers, snake_case stdin
|
||||
* payloads with `turn_id`/`model` extras and NO trailing newline, no env vars and
|
||||
* no command substitution, and a block-only decision model (allow/ask are not
|
||||
* honored — a hook can only block, never pre-approve). The dialect-agnostic
|
||||
* This bridge supports five of Codex's ten current hook points (`PreToolUse`,
|
||||
* `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`), regex-only
|
||||
* matchers, snake_case stdin payloads with `turn_id`/`model` extras and no
|
||||
* trailing newline, no config-time placeholder substitution or plugin-env
|
||||
* injection, and no pre-tool approval or rewrite path. The dialect-agnostic
|
||||
* primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the
|
||||
* Codex-specific payloads + matcher mode + decision mapping.
|
||||
* Codex-shaped payloads, matcher mode, and decision mapping.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-hooks-codex
|
||||
*/
|
||||
@@ -263,9 +262,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
// Stop → ContinuationDecision. A blocking Stop hook forces continuation.
|
||||
// TODO(stop-loop-guard): like CC, a Stop hook that unconditionally blocks would
|
||||
// force-continue every step (`stop_hook_active` is always false here); the
|
||||
// loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
|
||||
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
|
||||
// avoid continuing the same turn indefinitely. It is always false here, so an
|
||||
// unconditionally blocking hook force-continues every step until it self-limits.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
|
||||
const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -116,10 +116,10 @@ describe('hooks-codex bridge', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
|
||||
})
|
||||
|
||||
it('only the five Codex events are honored — a SubagentStop entry is ignored', async () => {
|
||||
it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
|
||||
const dir = configDir()
|
||||
const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
// SubagentStop is NOT a Codex event; it must be dropped (no crash, no effect).
|
||||
// SubagentStop is a current Codex event that this bridge drops (no crash, no effect).
|
||||
writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
|
||||
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts'
|
||||
|
||||
describe('parseCodexConfig', () => {
|
||||
it('honors only the five Codex events, dropping unknown ones', () => {
|
||||
it('honors only the five bridge-supported Codex events, dropping the rest', () => {
|
||||
const { config } = parseCodexConfig({
|
||||
PreToolUse: [{ hooks: [{ type: 'command', command: 'a.sh' }] }],
|
||||
SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // not a Codex event
|
||||
Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // not a Codex event
|
||||
SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // current Codex event, unsupported by this bridge
|
||||
Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // unknown to current Codex
|
||||
})
|
||||
expect(Object.keys(config)).toEqual(['PreToolUse'])
|
||||
expect(CODEX_EVENTS).toContain('PreToolUse')
|
||||
@@ -18,7 +18,7 @@ describe('parseCodexConfig', () => {
|
||||
Stop: [{ hooks: [{ type: 'command', command: '${NOT_SUBSTITUTED}/s.sh', timeout: 10 }] }],
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'u.sh', timeoutSec: 20 }] }],
|
||||
})
|
||||
// Codex does NO substitution — the literal ${…} survives.
|
||||
// The parser performs no config-time substitution; shell expansion happens later.
|
||||
expect(config.Stop).toEqual([{ hooks: [{ command: '${NOT_SUBSTITUTED}/s.sh', timeoutSec: 10 }] }])
|
||||
expect(config.UserPromptSubmit).toEqual([{ hooks: [{ command: 'u.sh', timeoutSec: 20 }] }])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user