fix(hooks): make block verdicts take effect, fix Stop, and propagate session ids - #747
Open
tamirkiviti13 wants to merge 4 commits into
Open
Conversation
…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
force-pushed
the
fix/user-prompt-submit-block-and-session-id
branch
from
August 11, 2026 07:29
8c1a234 to
c6086bb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
docs/HOOKS.mddocuments one exit-code contract for every hook event:1The engine honours that faithfully —
HookEngine.process_eventresolves ablockedverdict 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 twoStopdefects found while verifying it end-to-end.Four commits, each reviewable on its own.
1.
UserPromptSubmitblocks were ignored_collect_context_stdoutskips blocked results, so a blocking prompt hook produced no chunks, fell through to the "nothing to add" path, and returnedNone— 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
PromptBlockedreturned from the callback;run_with_mcpreturnsNonefor it, which is the shape it already returns for a cancelled run and whichcli_runneralready guards in three places. A return value rather than an exception, because_trigger_callbackscatches per-callback and appendsNone— a raise never reaches the runtime.Nested runs are a deliberate exception. A plugin making its own internal
run_with_mcpcall dereferences the result —shell_safety/register_callbacks.py:153doesresult.outputwith noNoneguard — so cancelling one would raiseAttributeError. Those substitute a notice for the prompt instead. Either way the prompt text is withheld. Sub-agents are unaffected: they usetemp_agent.run()and never fireUserPromptSubmit.Pending
SessionStartcontext 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.
PostToolUseblocks were ignoredThe same defect, one event over.
on_post_tool_callfired from thefinallyof the_call_toolwrapper — afterreturn resulthad 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 prependsPreToolUsestdout 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.
PreToolUseremains the way to stop a call from happening.The
finallystill 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 eightpost_tool_callregistrants are observational and return nothing.3.
Stopnever fired for the main agent_SUBAGENT_NAMEScontainedcode-puppyandcode_puppy— the name of the default agent — and classification substring-matched against it. Every top-level turn reportedSubagentStop, so aStophook was dead config. Confirmed live: a plaincode-puppy -p "..."run emittedSubagentStop.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_textreachedEventData.contextbut_build_stdin_payloadonly promotedresultandduration_ms, so aStophook fired withtool_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
UserPromptSubmitandStopever put asession_idin context — every tool event fell back to the literal"codepuppy-session", so a hook could not tell one run from another or pair aPreToolUsewith itsPostToolUse.run_with_mcpalready mints a per-rungroup_id; it just could not reach a callback invoked deep inside the run. It is now published through aContextVar(code_puppy/session_context.py), following the existing idiom intools/subagent_context.py. ContextVars copy into a task atcreate_tasktime 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.mdhadUserPromptSubmitmissing from the event table entirely, and listedStop/SubagentStopasCan Block: Yeswhen 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.pycovering turn cancellation, the nested-run fallback, pass-through of the existing additional-context contract,StopvsSubagentStopclassification, 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 checkclean.ruff format --checkflags onlyagent_creator_agent.pyand its test, which this branch does not touch and which are already unformatted onmain.Verified end-to-end against a live agent and a real model on 0.0.702, with each claim asserted programmatically rather than eyeballed:
Stopevent at allPre+Postpair; policy-violating call blocked withPreonly and noPost, proving it never executedStoppayload confirms the secret never entered the transcriptStopfires for the main agent with an exactresponse_textmatchOne 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 2will 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.