The two bridge plugins that run a user's existing Claude Code / Codex hook
config on the harness's typed interception seams, built on the shared
dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power
tool: anything it does a native cordis plugin does more powerfully — the
bridge exists only to run UNMODIFIED external hooks.
- dsh-hooks-claude: CC dialect. Seven hook points (SessionStart,
UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart,
SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/
${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher.
- dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points,
always-regex matcher, snake_case payloads (turn_id/model, no trailing
newline), no env/substitution, block-only decisions.
Both map the neutral merged outcome onto the seam's typed Decision and stamp
an explicit {kind:'plugin'} source on injected context (so it is never
mislabeled as a user prompt). Config parse-failure is contained; only command
hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop
loop-guard is deferred (TODO).
Tests: per-file 100% — config-parse unit branches + per-seam mappings
end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted
mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot
scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt
end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a
with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash
(verified on disk). The snapshot normalizer now scrubs hook/result.durationMs.
RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
6.4 KiB
RFC: dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges
Status: implemented (accepted 2026-06-30)
Context
The harness's extension surface is its typed interception seams (the interception-seams RFC): a "native hook" is just an ordinary cordis plugin subscribing to agent/session-start, agent/prompt-submit, tools/pre-execute, tools/post-execute, agent/turn-continuation, subagent/start, subagent/end. But users arrive with existing Claude Code (CC) and Codex hook configs — a hooks.json (or a settings file's hooks key) full of shell-command hooks — and want those to run unmodified. This RFC introduces the two bridge plugins that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library (the hook-protocol-lib RFC).
The framing that shapes the whole design: a bridge is a faithfulness adapter, not a power tool. Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full ctx, no serialization boundary. The bridge's only reason to exist is to run an UNMODIFIED external CC/Codex hook with byte-faithful semantics. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call runHook + mergeHookOutputs from the shared lib, map the neutral outcome onto a seam Decision.
Decision
Two independent plugins in the packages/hooks/ group, each a function/namespace plugin (name/inject/Config/apply, NO default export — see postmortem 0001) injecting only bash:
dsh-hooks-claude— the CC dialect. Seven hook points:SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,Stop,SubagentStart,SubagentStop. Owns CC's per-event stdin payloads (a base ofsession_id/cwd/hook_event_nameplus per-event fields), CC's env +${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR}substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a trailing newline.dsh-hooks-codex— the Codex dialect: a deliberate SUBSET. Five hook points (PreToolUse,PostToolUse,SessionStart,UserPromptSubmit,Stop— no subagent/notification/compaction), an always-regex matcher, snake_case payloads withturn_id/model/permission_modeextras written WITHOUT a trailing newline, no env and no${…}substitution, and a block-only decision model (a Codex hook can never pre-approve, soallow/askare not honored). Codex hardcodes a tool call'stool_nameto"Bash"andtool_inputto{ command }.
Outcome → Decision mapping
Each bridge maps the neutral MergedHookOutcome from the shared lib onto the seam's typed Decision:
| Seam | CC | Codex |
|---|---|---|
agent/session-start (emit) |
additionalContext → agent.inject() |
plain-stdout output → additionalContext → agent.inject() |
agent/prompt-submit |
deny→block; context→allow |
block→block; context→allow |
tools/pre-execute |
deny→deny; ask→ask |
block→deny (no allow/ask) |
tools/post-execute |
deny→block+feedback; context→accept |
same |
agent/turn-continuation |
blocking Stop → continue (reason = next-step steering) |
same |
subagent/start (emit) |
additionalContext → inject into the live child | — (not a Codex event) |
subagent/end (emit) |
observe-only | — |
Context source is always the plugin (the mislabel guard)
agent.inject() defaults a missing MessageSource to { kind: 'user' } — which would record plugin-injected context as if the user had typed it. So every bridge inject() and every HookContext passes an explicit { kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' } source. A test asserts the resulting context/message.source is the plugin, never user.
Containment
The config is parsed ONCE at load; a read/parse failure logs and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only type: 'command' hooks run — a prompt/agent/HTTP hook (CC) or an async: true / non-command hook (Codex) is parsed-and-skipped with a warning. The emit-listener paths (session-start, subagent/start) run detached, with their inject contained in a .catch that logs (a throwing inject must not break session boot or the loop).
Deferred (faithful-but-degraded)
- Tool-input rewrite. A CC/Codex
updatedInputis logged + warned, not honored — input rewrite is a deferred consistency-design problem (the pre-tool-input-rewrite RFC), because the pre-execution args are read bytool/callaudit +assistant/messagehistory + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field. - Stop loop-guard (
TODO(stop-loop-guard)). CC/Codex break an infinite force-continue withstop_hook_active(true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. Todaystop_hook_activeis alwaysfalse, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. - Permission
askdegrades todenyat thetools/pre-executeseam (FIXME(permissions)in the interception-seams RFC) — there is no interactive permission prompt yet. - Config discovery. The path is explicit in
cordis.yml; the full multi-layer CC/Codex precedence walk and the trust/hash model are not reimplemented (TODO).
Consequences
The bridges are thin and readable standalone: the correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in the shared dsh-hook-protocol, so each bridge is just config-parse + payload-build + outcome-map. Each is covered at per-file 100% — config-parse branches as unit tests, and the seam mappings end-to-end through the REAL loop + REAL dsh-bash-local + REAL shell scripts from a temp hooks.json (a scripted mock MODEL is the only stand-in), plus a real-Loader export-shape guard so a stray default export can't silently drop inject. Because the seams already carry typed Decisions, a future native plugin needs none of this bridge machinery — it returns a Decision directly.