diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index a8cc3df4db..3bedd73b34 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -37,7 +37,7 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se ### 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. +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`). The fold differs by seam because the two Decision unions differ: `tools/post-execute` — a downstream `block`/`accept` both carry an `additionalContext` field, so the bridge context rides along either way (a downstream block wins AND keeps the context; a downstream accept keeps its content rewrite and gains the context). `agent/prompt-submit` — a downstream `allow` gains the bridge context (and keeps its own content rewrite / additionalContext), but `PromptDecision.block` carries no context field, so a downstream block drops the bridge context — which is correct: a blocked prompt never reaches the model, so context attached to it is moot. Only a real `deny`/`block` from the hook itself 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 diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 3468e1c037..6151a11c44 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -214,7 +214,12 @@ export function apply(ctx: Context, config: Config): void { /** * 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. + * additionalContext onto a delegated decision drops neither. The merged block + * carries a single `source` — this bridge's — because a `HookContext` holds one + * `MessageSource` and the seam cannot represent mixed provenance; the rendered + * `context/message` only distinguishes by `source.kind` ('plugin'), so a + * downstream plugin's text is still correctly framed as plugin context, not a + * user prompt. */ function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { if (!theirs) return ours diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index d2e01f4c4d..63e2f611ce 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -433,7 +433,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => 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(). + // block the prompt. The bridge delegates via next() and folds its context. 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 }] }] }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 393e157cba..a704a6af24 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -169,7 +169,11 @@ export function apply(ctx: Context, config: Config): void { /** * 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. + * additionalContext onto a delegated decision drops neither. The merged block + * carries a single `source` — this bridge's — because a `HookContext` holds one + * `MessageSource` and the seam cannot represent mixed provenance; the rendered + * `context/message` only distinguishes by `source.kind` ('plugin'), so a + * downstream plugin's text is still correctly framed as plugin context. */ function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { if (!theirs) return ours diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 3bfc3edb03..87032c98ce 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -71,8 +71,8 @@ describe('hooks-codex coverage — decision mapping paths', () => { 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(). + // policy plugin registered after the bridge) must still get to block. The + // bridge delegates via next() and folds its context onto the decision. 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')])