Skip to content

fix(hooks): make block verdicts take effect, fix Stop, and propagate session ids - #747

Open
tamirkiviti13 wants to merge 4 commits into
mpfaffenberger:mainfrom
tamirkiviti13:fix/user-prompt-submit-block-and-session-id
Open

fix(hooks): make block verdicts take effect, fix Stop, and propagate session ids#747
tamirkiviti13 wants to merge 4 commits into
mpfaffenberger:mainfrom
tamirkiviti13:fix/user-prompt-submit-block-and-session-id

Conversation

@tamirkiviti13

@tamirkiviti13 tamirkiviti13 commented Aug 10, 2026

Copy link
Copy Markdown

docs/HOOKS.md documents one exit-code contract for every hook event:

Code Meaning Effect
1 Block Tool is prevented.

The engine honours that faithfully — HookEngine.process_event resolves a blocked verdict for whatever event it is given. Two of its consumers then throw that verdict away, so a hook author who follows the documentation gets a silent no-op. That is the thread running through this PR, plus two Stop defects found while verifying it end-to-end.

Four commits, each reviewable on its own.


1. UserPromptSubmit blocks were ignored

_collect_context_stdout skips blocked results, so a blocking prompt hook produced no chunks, fell through to the "nothing to add" path, and returned None — the prompt reached the model completely unchanged.

A block now cancels the turn. No agent is built, no LLM call is made, and the reason is shown to the user. The signal is a PromptBlocked returned from the callback; run_with_mcp returns None for it, which is the shape it already returns for a cancelled run and which cli_runner already guards in three places. A return value rather than an exception, because _trigger_callbacks catches per-callback and appends None — a raise never reaches the runtime.

Nested runs are a deliberate exception. A plugin making its own internal run_with_mcp call dereferences the result — shell_safety/register_callbacks.py:153 does result.output with no None guard — so cancelling one would raise AttributeError. Those substitute a notice for the prompt instead. Either way the prompt text is withheld. Sub-agents are unaffected: they use temp_agent.run() and never fire UserPromptSubmit.

Pending SessionStart context is deliberately not drained on a block — that prompt never ran, so its context belongs to the next one that does. There is a test for it.

2. PostToolUse blocks were ignored

The same defect, one event over. on_post_tool_call fired from the finally of the _call_tool wrapper — after return result had already decided the value — and its return was discarded. A hook could see a secret in a tool's output and had no way to keep it out of the model.

The callback now runs on the success path before the result is handed back, and a {"blocked": True, "reason": ...} verdict substitutes a notice. This reuses the rewrite point a few lines above that already prepends PreToolUse stdout to a tool result.

Scope, stated plainly in the docs: the tool has already run and its side effects have happened. The verdict controls where the output goes — out of the model's context, out of message history, out of your provider's logs. PreToolUse remains the way to stop a call from happening.

The finally still fires on the exception path, where there is no result to withhold; a flag stops a successful call notifying twice. Additive for existing consumers: all eight post_tool_call registrants are observational and return nothing.

3. Stop never fired for the main agent

_SUBAGENT_NAMES contained code-puppy and code_puppy — the name of the default agent — and classification substring-matched against it. Every top-level turn reported SubagentStop, so a Stop hook was dead config. Confirmed live: a plain code-puppy -p "..." run emitted SubagentStop.

The name was never a sound signal either way — a sub-agent can be called anything, and the default agent collides with the list. Classification now uses subagent_context.is_subagent(), the depth ContextVar that actually tracks nesting, keeping the name list only as a fallback for a run ended outside the context manager.

4. The end-of-turn payload had no response, and every event shared one session id

response_text reached EventData.context but _build_stdin_payload only promoted result and duration_ms, so a Stop hook fired with tool_input: {} and nothing to inspect. Claude Code hands end-of-turn hooks a transcript path; code puppy has no transcript file, so the response itself is the equivalent.

Separately, only UserPromptSubmit and Stop ever put a session_id in context — every tool event fell back to the literal "codepuppy-session", so a hook could not tell one run from another or pair a PreToolUse with its PostToolUse. run_with_mcp already mints a per-run group_id; it just could not reach a callback invoked deep inside the run. It is now published through a ContextVar (code_puppy/session_context.py), following the existing idiom in tools/subagent_context.py. ContextVars copy into a task at create_task time and writes stay task-local, so a sub-agent run gets its own id and cannot clobber its parent's. Events outside any run keep the placeholder.

Block reasons also stopped leaking internals: users were shown Hook '<full command line>' failed: …, which names the hook's path and says "failed" for a hook that deliberately blocked. Both the prompt and tool paths now prefer the hook's own stderr.


Docs

docs/HOOKS.md had UserPromptSubmit missing from the event table entirely, and listed Stop/SubagentStop as Can Block: Yes when the bridge only observes them. Both corrected, with worked examples for blocking a prompt and withholding tool output.

Testing

21 new tests in tests/plugins/test_claude_code_hooks_prompt_block.py covering turn cancellation, the nested-run fallback, pass-through of the existing additional-context contract, Stop vs SubagentStop classification, the end-of-turn payload, PostToolUse withholding, human-facing block reasons, and ContextVar task inheritance/isolation.

Full suite on current main + this branch: 7124 passed, 8 skipped. ruff check clean. ruff format --check flags only agent_creator_agent.py and its test, which this branch does not touch and which are already unformatted on main.

Verified end-to-end against a live agent and a real model on 0.0.702, with each claim asserted programmatically rather than eyeballed:

  • benign prompt passes; malicious prompt cancels the turn with no Stop event at all
  • benign tool call runs with a Pre+Post pair; policy-violating call blocked with Pre only and no Post, proving it never executed
  • a secret in tool output is withheld — the agent replies "I can't see what's in that file", where before it echoed the value back — and the Stop payload confirms the secret never entered the transcript
  • Stop fires for the main agent with an exact response_text match
  • all run events in a turn share one session UUID

One caveat worth stating: withheld output is still printed in the operator's own terminal by code puppy's shell display, which runs before the hook. This protects the model context, not the local screen.

Note on exit codes

Not changed, just flagged. Code puppy's exit codes are inverted relative to Claude Code — Claude uses exit 2 as the universal block; code puppy documents exit 1 = block, exit 2 = non-blocking feedback. That is your documented contract so I left it alone, but it does mean a Claude-authored hook using exit 2 will not block here. The JSON forms ({"decision":"block"}, permissionDecision:"deny") behave identically in both.

Context

Found while building tooling that uses code puppy's hooks as a policy enforcement point. Happy to split any commit out, adjust the wording of the block notices, or plumb the session id through the callback signatures explicitly instead of via ContextVar.

@tamirkiviti13 tamirkiviti13 changed the title fix(hooks): honor UserPromptSubmit blocks and propagate the real session id fix(hooks): make UserPromptSubmit blocks actually block, and propagate the real session id Aug 11, 2026
…ion id

Two things the Claude Code hooks bridge computed and then discarded.

**UserPromptSubmit blocks were ignored.** The hook engine resolves a
`blocked` verdict (exit code 1, or a `deny`/`block` stdout control
payload) for UserPromptSubmit just as it does for PreToolUse, but the
bridge never read the flag: `_collect_context_stdout` skips blocked
results, so a blocking prompt hook fell through to the "nothing to add"
path and the prompt reached the model unchanged. A hook author writing
the documented `exit 1` got a silent no-op — the worst failure mode for
something people reach for as a policy control.

A block now replaces the prompt with a notice carrying the hook's
reason, so the original text never reaches the model. The turn still
runs, on the replacement — there is no callback-driven way to cancel one
— and the notice tells the model to relay the block and stop.

Pending SessionStart context is deliberately left in the buffer on a
block: that prompt never ran, so its context still belongs to the next
prompt that does.

**Every event reported the same session id.** `_build_stdin_payload`
reads `session_id` from `EventData.context`, but only UserPromptSubmit
and Stop/SubagentStop ever put one there — so tool events fell back to
the literal `"codepuppy-session"` and a hook script could not tell one
run from another, nor pair a PreToolUse with its PostToolUse.

`run_with_mcp` already mints a per-run `group_id`; it just had no way to
reach a callback that pydantic-ai invokes deep inside the run. It is now
published through a ContextVar (`code_puppy.session_context`), which the
bridge reads when building any event's context. ContextVars are copied
into a task at `create_task` time and writes stay task-local, so a
sub-agent run gets its own id and can never clobber its parent's. The
wrapper restores the previous id on the way out. Events fired outside
any run, such as SessionStart at boot, keep the existing placeholder.

Docs updated: UserPromptSubmit was missing from the event table, and
Stop/SubagentStop were listed as blocking when the bridge only observes
them.
Builds on the previous commit, which stopped a block from being ignored
but could only substitute the prompt — the turn still ran, on
replacement text. Claude Code, whose semantics this bridge targets,
erases the prompt and processes nothing. This closes that gap.

A blocking hook now cancels the turn outright: no agent is built, no LLM
call is made, and the reason is surfaced to the user. The signal is a
`PromptBlocked` returned from a `user_prompt_submit` callback, which
`run_with_mcp` returns `None` for — the shape it already returns for a
cancelled run, and one `cli_runner` already guards in three places.

A return value rather than an exception because `_trigger_callbacks`
catches `Exception` per callback and appends `None`, so a raise never
reaches the runtime.

Nested runs are the exception, and deliberately so. A plugin making its
own internal `run_with_mcp` call dereferences the result —
`shell_safety` does `result.output` with no None guard — so cancelling
one would break the caller with an AttributeError. Those fall back to
substitution. The prompt text is withheld from the model either way.
Sub-agents are unaffected: they go through `temp_agent.run()` and never
fire UserPromptSubmit at all.

Tests cover the top-level cancellation (asserting nothing downstream of
the block runs), the nested fallback, and that a plain-string return
still replaces the prompt as before.
Two defects found while exercising the hooks end-to-end against a real
agent run, plus a message-quality fix. All three are in the path the
previous commits touch.

**Stop never fired.** ``_SUBAGENT_NAMES`` contained ``code-puppy`` and
``code_puppy`` — the name of the DEFAULT agent — and the classifier
substring-matched the agent name against it. Every top-level turn was
therefore reported as ``SubagentStop`` and a ``Stop`` hook was dead
config. Confirmed live: a plain ``code-puppy -p "..."`` run emitted
``SubagentStop``.

The name was never a sound signal in either direction: a sub-agent can
be called anything, and the default agent collides with the list.
Classification now uses ``subagent_context.is_subagent()`` — the depth
ContextVar that actually tracks nesting — and keeps the name list only
as a fallback for a run ended outside the context manager, with the two
default-agent names removed. The existing test asserting ``code-puppy``
implies ``SubagentStop`` encoded the bug and is updated.

**The end-of-turn payload had no response.** ``agent_run_end`` receives
``response_text``, and it reached ``EventData.context``, but
``_build_stdin_payload`` only promotes ``result`` and ``duration_ms`` —
so a Stop hook fired with ``tool_input: {}`` and nothing to inspect.
Claude Code hands end-of-turn hooks a ``transcript_path``; code puppy
has no transcript file, so the response itself is the equivalent, and
without it end-of-turn review is impossible.

**Block reasons leaked internals.** ``blocking_reason`` is a diagnostic
string — ``Hook '<full command line>' failed: <stderr>`` — and it was
what the user saw on a blocked prompt and what the model saw on a
blocked tool call. It exposes the hook's path and says "failed" for a
hook that deliberately blocked. Both now prefer the blocking hook's own
stderr, falling back to the diagnostic form when there is none.
PostToolUse was purely observational: `on_post_tool_call` fired from the
`finally` of the `_call_tool` wrapper, after `return result` had already
decided the value, and its return was discarded. A hook could see a
secret in a tool's output and had no way to keep it out of the model.

It now runs on the success path, before the result is handed back, and a
`{"blocked": True, "reason": ...}` verdict substitutes a notice naming
the reason. The mechanism is the one already used a few lines above to
prepend PreToolUse stdout to a tool result, so the path is well-trodden.

Scope is deliberately narrow and the docs say so: the tool has ALREADY
run and its side effects have happened. This governs what reaches the
model and the message history — which is what keeps secrets in tool
output out of the transcript and out of the provider's logs. `PreToolUse`
remains the way to stop a call from happening.

The `finally` still fires on the exception path, where there is no result
to withhold and the notification stays observational; a flag stops a
successful call from notifying twice.

Additive for existing consumers. All eight `post_tool_call` registrants
(run stats, subagent panel, ACP bridge, heartbeat, frontend emitter,
quick-resume, herdr, the hooks bridge) are observational and return
nothing, so none of them change behaviour.

Verified end-to-end against a live agent: `cat` a file containing a
marker, hook blocks, and the agent reports it cannot see the contents —
where previously it echoed the secret straight back.
@tamirkiviti13
tamirkiviti13 force-pushed the fix/user-prompt-submit-block-and-session-id branch from 8c1a234 to c6086bb Compare August 11, 2026 07:29
@tamirkiviti13 tamirkiviti13 changed the title fix(hooks): make UserPromptSubmit blocks actually block, and propagate the real session id fix(hooks): make block verdicts take effect, fix Stop, and propagate session ids Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant