瀏覽代碼

fix(hooks): delegate context-only hooks + default CLAUDE_PROJECT_DIR

Address review on the hook-bridges PR — two composability/compatibility bugs
in both the CC and Codex bridges:

1. A hook that only attaches additionalContext (no block/deny) returned
   `allow`/`accept` WITHOUT calling next(), short-circuiting every later
   agent/prompt-submit / tools/post-execute listener. A policy/sandbox plugin
   registered after the bridge never saw the prompt. Now the context-only path
   delegates via next() and folds its context onto the downstream decision
   (concatContext): a downstream block/deny still wins and carries the bridge
   context; a downstream allow/accept keeps its own content rewrite and gains
   the context. Only a real hook deny/block short-circuits.

2. CLAUDE_PROJECT_DIR was empty in the default ACP wiring (no projectDir
   configured), breaking common unmodified hooks that reference
   $CLAUDE_PROJECT_DIR. It now defaults per-run to the agent's session
   workspace (the same cwd the hook runs in); an explicit config.projectDir
   still wins.

Regression tests per bridge: a later listener blocks a prompt a context-only
hook allowed; both contexts survive when the downstream also adds one; the
default CLAUDE_PROJECT_DIR reaches the hook. Each proven red on the pre-fix
code.
Tianyi Cui 3 月之前
父節點
當前提交
9bc4df28c1

+ 10 - 2
docs/rfc/implemented/feature/2026-06-30-hook-bridges.md

@@ -24,9 +24,9 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se
 | 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` |
+| `agent/prompt-submit` | `deny`→`block`; context-only→delegate+fold | `block`→`block`; context-only→delegate+fold |
 | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) |
-| `tools/post-execute` | `deny`→`block`+feedback; context→`accept` | same |
+| `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | 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 | — |
@@ -35,6 +35,14 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se
 
 `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`.
 
+### Adding context is not a veto — delegate, then fold
+
+A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. So on the context-only path each bridge **delegates via `next()`** and then **folds** its `additionalContext` onto the downstream decision (`concatContext`): a downstream `block`/`deny` still wins (and carries the bridge context too), a downstream `allow`/`accept` keeps its own content rewrite and gains the bridge context. Only a real `deny`/`block` from the hook short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed, and that both contexts survive when the downstream also adds one.
+
+### CLAUDE_PROJECT_DIR defaults to the session workspace
+
+Claude Code always exports `CLAUDE_PROJECT_DIR`, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths. An explicit `config.projectDir` wins; when it is omitted (the default ACP wiring configures only `configPath`), the bridge defaults the env var per-run to the agent's session workspace — the same `session.header.cwd` the hook already runs in — rather than leaving it empty. So a stock project-relative hook works in the default setup.
+
 ### 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).

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

@@ -11,7 +11,7 @@ import type { Config } from '@deepseek-ai/dsh-hooks-claude'
 const config: Config = {
   configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key
   pluginRoot: '/path/to/plugin',     // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
-  projectDir: '/path/to/project',    // optional: replaces ${CLAUDE_PROJECT_DIR} AND set as the hook env var
+  projectDir: '/path/to/project',    // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
   defaultTimeoutMs: 600_000,         // optional: per-hook timeout when a hook sets none (CC default)
 }
 ```
@@ -34,9 +34,9 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
 | CC hook | Harness seam | Mapping |
 |---|---|---|
 | `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) |
-| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext → `allow` with context |
+| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a later listener can still block/rewrite) |
 | `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` |
-| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext → `accept` with context |
+| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
 | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering |
 | `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child |
 | `SubagentStop` | `subagent/end` (emit) | observe-only |

+ 53 - 8
packages/hooks/hooks-claude/src/index.ts

@@ -59,9 +59,17 @@ export interface Config {
    * `hooks.json` from each `session/new.cwd` is not yet implemented.
    */
   configPath: string
-  /** Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir). */
+  /**
+   * Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).
+   */
   pluginRoot?: string
-  /** Replaces `${CLAUDE_PROJECT_DIR}` in command strings + set as the hook env var. */
+  /**
+   * Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the
+   * `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var
+   * defaults per-run to the agent's session workspace (`session.header.cwd`, the
+   * same dir the hook runs in) — Claude Code always exports this var, and common
+   * unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
+   */
   projectDir?: string
   /** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
   defaultTimeoutMs?: number
@@ -111,7 +119,6 @@ export function apply(ctx: Context, config: Config): void {
   }
 
   const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
-  const hookEnv = config.projectDir !== undefined ? { CLAUDE_PROJECT_DIR: config.projectDir } : undefined
 
   /**
    * Run every command hook configured for `point` whose matcher selects
@@ -136,6 +143,15 @@ export function apply(ctx: Context, config: Config): void {
     // operate in the user's project tree. Absent for a no-agent run (falls back
     // to the executor default).
     const workdir = opts.agent?.session.header.cwd
+    // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to
+    // the session workspace (the same dir the hook RUNS in). Claude Code always
+    // exports this var, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR`
+    // (shell expansion at run time) for project-relative paths — leaving it empty
+    // in the default ACP wiring (no `projectDir` configured) would break them even
+    // though the bridge already knows the workspace. Absent only for a no-agent run
+    // with no configured projectDir (nothing to point at).
+    const projectDir = config.projectDir ?? workdir
+    const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
     for (const group of groups) {
       if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue
       for (const hook of group.hooks) {
@@ -195,6 +211,16 @@ export function apply(ctx: Context, config: Config): void {
     return { content, source: PLUGIN_SOURCE }
   }
 
+  /**
+   * Concatenate this bridge's {@link HookContext} (`ours`, always present at the
+   * call sites) with a downstream listener's optional one, so folding our
+   * additionalContext onto a delegated decision drops neither.
+   */
+  function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
+    if (!theirs) return ours
+    return { content: [...ours.content, ...theirs.content], source: ours.source }
+  }
+
   // --- SessionStart: emit (cannot block). Inject any additionalContext into the
   // agent. The matcher subject is the source.
   // TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and
@@ -223,9 +249,18 @@ export function apply(ctx: Context, config: Config): void {
     if (merged.decision === 'deny') {
       return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
     }
-    const context = contextFrom(merged)
-    if (context) return { kind: 'allow', additionalContext: context }
-    return next()
+    // Our hooks did not block. DELEGATE (attaching context alone is not a veto):
+    // a later `agent/prompt-submit` listener must still get to block or rewrite.
+    // Then fold our additionalContext onto its decision — a downstream block wins
+    // (a dropped prompt makes the context moot; `block` carries no context field).
+    const downstream = await next()
+    const ours = contextFrom(merged)
+    if (!ours || downstream.kind !== 'allow') return downstream
+    return {
+      kind: 'allow',
+      ...downstream.content !== undefined ? { content: downstream.content } : {},
+      additionalContext: concatContext(ours, downstream.additionalContext),
+    }
   })
 
   // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
@@ -245,8 +280,18 @@ export function apply(ctx: Context, config: Config): void {
     if (merged.decision === 'deny') {
       return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} }
     }
-    if (context) return { kind: 'accept', additionalContext: context }
-    return next()
+    // Our hooks did not block. DELEGATE so a later listener can still block/replace,
+    // then fold our context onto its decision (a downstream block carries it too).
+    const downstream = await next()
+    if (!context) return downstream
+    if (downstream.kind === 'block') {
+      return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) }
+    }
+    return {
+      kind: 'accept',
+      ...downstream.content !== undefined ? { content: downstream.content } : {},
+      additionalContext: concatContext(context, downstream.additionalContext),
+    }
   })
 
   // --- Stop → ContinuationDecision. CC's Stop hook can force the conversation to

+ 107 - 0
packages/hooks/hooks-claude/tests/coverage.spec.ts

@@ -410,6 +410,113 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
     expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
   })
 
+  it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => {
+    // The default ACP wiring sets no projectDir. A stock CC hook that references
+    // $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace,
+    // not an empty string. The hook echoes the var as additionalContext.
+    const d = dir()
+    const workspace = dir()
+    const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n')
+    const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
+    const adapter = new MockAdapter([textResponse('ran')])
+    const ctx = await harness(path, adapter) // NB: no projectDir
+    // The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
+    const { SessionId } = await import('@deepseek-ai/dsh-session')
+    const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
+    handle.agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, handle.agent as ReactLoopAgent)
+    expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
+      && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
+    await handle.dispose()
+  })
+
+  it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
+    // A hook that only adds context must NOT short-circuit the waterfall: a
+    // downstream agent/prompt-submit listener (a policy plugin) must still get to
+    // block the prompt. Before the fix the bridge returned `allow` without next().
+    const d = dir()
+    const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
+    const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
+    const adapter = new MockAdapter([textResponse('should not run')])
+    const ctx = await harness(path, adapter)
+    // A later listener that blocks every prompt (registered AFTER the bridge).
+    const { AgentId: AId } = await import('@deepseek-ai/dsh-agent')
+    ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
+    const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    // the downstream block won: the model was never called, no user/message was
+    // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
+    expect(adapter.requests).toHaveLength(0)
+    expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
+    const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
+    expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
+  })
+
+  it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => {
+    // Both the bridge hook and a later prompt-submit listener attach context; the
+    // request must see BOTH (concatContext keeps the downstream one too).
+    const d = dir()
+    const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n')
+    const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
+    const adapter = new MockAdapter([textResponse('ok')])
+    const ctx = await harness(path, adapter)
+    ctx.on('agent/prompt-submit', async () => ({
+      kind: 'allow' as const,
+      content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
+      additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
+    }))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    const req = JSON.stringify(adapter.requests[0]!.messages)
+    expect(req).toContain('from-bridge')
+    expect(req).toContain('from-downstream')
+    expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved
+    // the original prompt was replaced by the downstream rewrite
+    const userMsg = events(agent).find(e => e.type === 'user/message')
+    expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
+  })
+
+  it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
+    // The bridge hook adds context; a later post-execute listener accepts with a
+    // content rewrite. Both the rewrite and the bridge context survive.
+    const d = dir()
+    const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
+    const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
+    const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
+    const ctx = await harness(path, adapter)
+    ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+    ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    const result = events(agent).find(e => e.type === 'tool/result')
+    expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
+    expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+  })
+
+  it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
+    // The bridge hook only adds context; a later post-execute listener blocks the
+    // result. The block wins AND carries the bridge context (concatContext on the
+    // block arm).
+    const d = dir()
+    const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
+    const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
+    const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
+    const ctx = await harness(path, adapter)
+    ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+    ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    const result = events(agent).find(e => e.type === 'tool/result')
+    expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
+    expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
+    // the bridge's context still landed (folded onto the block)
+    expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+  })
+
 })
 
 describe('hooks-claude coverage — executor reject + no-open-turn', () => {

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

@@ -40,9 +40,9 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
 | Codex hook | Harness seam | Mapping |
 |---|---|---|
 | `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` |
-| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext → `allow` with context |
+| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
 | `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) |
-| `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext → `accept` with context |
+| `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
 | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering |
 
 A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.

+ 32 - 5
packages/hooks/hooks-codex/src/index.ts

@@ -166,6 +166,16 @@ export function apply(ctx: Context, config: Config): void {
     return { content, source: PLUGIN_SOURCE }
   }
 
+  /**
+   * Concatenate this bridge's {@link HookContext} (`ours`, always present at the
+   * call sites) with a downstream listener's optional one, so folding our
+   * additionalContext onto a delegated decision drops neither.
+   */
+  function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
+    if (!theirs) return ours
+    return { content: [...ours.content, ...theirs.content], source: ours.source }
+  }
+
   // SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
   // TODO(session-start-gating): a synchronous emit + detached `.then`, so the
   // injected context is BEST-EFFORT — not guaranteed before the first turn reaches
@@ -185,9 +195,16 @@ export function apply(ctx: Context, config: Config): void {
     const turn = lastTurn(agent)
     const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
     if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
-    const context = contextFrom(merged)
-    if (context) return { kind: 'allow', additionalContext: context }
-    return next()
+    // Context alone is not a veto: DELEGATE so a later prompt-submit listener can
+    // still block/rewrite, then fold our context onto its decision.
+    const downstream = await next()
+    const ours = contextFrom(merged)
+    if (!ours || downstream.kind !== 'allow') return downstream
+    return {
+      kind: 'allow',
+      ...downstream.content !== undefined ? { content: downstream.content } : {},
+      additionalContext: concatContext(ours, downstream.additionalContext),
+    }
   })
 
   // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
@@ -206,8 +223,18 @@ export function apply(ctx: Context, config: Config): void {
     if (merged.decision === 'deny') {
       return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} }
     }
-    if (context) return { kind: 'accept', additionalContext: context }
-    return next()
+    // Context alone is not a veto: DELEGATE, then fold our context onto the
+    // downstream decision (a downstream block carries it too).
+    const downstream = await next()
+    if (!context) return downstream
+    if (downstream.kind === 'block') {
+      return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) }
+    }
+    return {
+      kind: 'accept',
+      ...downstream.content !== undefined ? { content: downstream.content } : {},
+      additionalContext: concatContext(context, downstream.additionalContext),
+    }
   })
 
   // Stop → ContinuationDecision. A blocking Stop hook forces continuation.

+ 64 - 0
packages/hooks/hooks-codex/tests/coverage.spec.ts

@@ -69,6 +69,70 @@ describe('hooks-codex coverage — decision mapping paths', () => {
     expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
   })
 
+  it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
+    // Context alone is not a veto: a downstream agent/prompt-submit listener (a
+    // policy plugin registered after the bridge) must still get to block. Before
+    // the fix the bridge returned `allow` without calling next().
+    const d = dir()
+    hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
+    const adapter = new MockAdapter([textResponse('should not run')])
+    const ctx = await harness(join(d, 'hooks.json'), adapter)
+    ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+    expect(adapter.requests).toHaveLength(0)
+    expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
+    const te = events(agent).findLast(e => e.type === 'turn/end')
+    expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
+  })
+
+  it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => {
+    const d = dir()
+    hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
+    const adapter = new MockAdapter([textResponse('ok')])
+    const ctx = await harness(join(d, 'hooks.json'), adapter)
+    ctx.on('agent/prompt-submit', async () => ({
+      kind: 'allow' as const,
+      content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
+      additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
+    }))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+    const req = JSON.stringify(adapter.requests[0]!.messages)
+    expect(req).toContain('from-bridge')
+    expect(req).toContain('from-downstream')
+    expect(req).toContain('rewritten-prompt')
+  })
+
+  it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
+    const d = dir()
+    hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
+    const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
+    const ctx = await harness(join(d, 'hooks.json'), adapter)
+    ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+    ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+    const result = events(agent).find(e => e.type === 'tool/result')
+    expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
+    expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+  })
+
+  it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
+    const d = dir()
+    hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
+    const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
+    const ctx = await harness(join(d, 'hooks.json'), adapter)
+    ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+    ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+    const result = events(agent).find(e => e.type === 'tool/result')
+    expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
+    expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
+    expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+  })
+
   it('SessionStart additionalContext is injected for the first request', async () => {
     const d = dir()
     hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] })