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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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') }] }] })
|
||||
|
||||
Reference in New Issue
Block a user