Skip to content

Hook responses are always serialized in legacy Claude Code format — caller context discarded by parsing stdin instead of reading it #196

Description

@tupe12334

Summary

steplock serializes every hook response in the legacy Claude Code shape, regardless of which agent actually invoked it, because the CLI parses stdin with polyhook::parse::parse_event instead of reading it through polyhook::read_from.

polyhook_core records the detected caller and event type in thread-locals during the read, and polyhook::respond reads them back to pick the right wire format:

// polyhook_core::read_from
LAST_CALLER.with(|c| { *c.borrow_mut() = event.caller; });
LAST_EVENT.with(|e| { *e.borrow_mut() = Some(event.event); });

// polyhook_core::respond_to
let caller = LAST_CALLER.with(|c| *c.borrow());
let event  = LAST_EVENT.with(|e| *e.borrow());
serialize_response_with_event(response, caller, event)

core/src/bin/main.rs::run_app bypasses that read path entirely:

https://github.com/polyhook/steplock/blob/f33cabd/core/src/bin/main.rs#L184-L199

let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
let ph_event = parse::parse_event(&bytes)?;   // <-- thread-locals never set

So at polyhook::respond time the context is still the default caller = Unknown, event = None, which routes to serialize_claude_code(resp, None)is_pre_tool_use == false → the legacy top-level decision: "block".

Two distinct consequences

1. Claude Code PreToolUse blocks kill the entire session. Per Claude Code's hook reference, top-level decision: "block" aborts the whole agent session, whereas hookSpecificOutput.permissionDecision: "deny" denies just that one tool call. A quality gate is supposed to say "not yet, answer this question and retry" — instead the agent's session dies on the first gated git push, which also makes the "then retry your original command" instruction in steplock's own block message impossible to follow.

2. Non-Claude-Code agents get a response they cannot parse, so the gate fails open. This defeats steplock's core cross-agent premise (README.md: "regardless of which AI tool the agent runs in").

Reproduction

Built from main @ f33cabd (cargo build --release -p steplock), with a two-item checklist under .steplock/checklists/gate/:

# config.toml
on_event = "tool:before"
on_tool = "bash"
match_input = "input.command.contains('git push')"
reset = "session"
# flow.mmd
stateDiagram-v2
    [*] --> a
    a --> b
    b --> [*]
    a: Did you run the tests?
    b: Did you update the changelog?

Claude Code payload

$ echo '{"session_id":"sess1","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git push origin main"}}' | steplock
{"decision":"block","reason":"[gate: 1/2] Did you run the tests? ..."}
  • Observed: top-level decision: "block" — terminates the Claude Code session.
  • Expected: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}

Cline payload

$ echo '{"type":"beforeToolUse","toolName":"bash","args":{"command":"git push origin main"},"session":"cl1"}' | steplock
{"decision":"block","reason":"[gate: 1/2] Did you run the tests? ..."}
  • Observed: Claude Code format.
  • Expected: {"approved":false,"reason":"..."}

Windsurf payload

$ echo '{"event":"pre_tool","tool":"run_command","parameters":{"command":"git push origin main"},"session":"ws1"}' | steplock
{"decision":"block","reason":"[gate: 1/2] Did you run the tests? ..."}
  • Observed: Claude Code format.
  • Expected: {"allow":false,"reason":"..."}

polyhook correctly detects all three callers — polyhook_to_hook_event even copies caller onto steplock's own HookEvent, and the existing polyhook_event_maps_correctly test asserts event.caller == "claude-code". The caller is known; it is just discarded before serialization.

Why the test suite doesn't catch it

Every existing run_app test asserts only on the Rust enum variant (matches!(resp, polyhook::HookResponse::BlockResponse(_))) and never serializes the response, so the entire caller-format layer is untested:

  • core/src/bin/main.rsrun_app_blocks_matching_command, run_app_approves_non_matching_command

Version tested

Repo main @ f33cabd, core/Cargo.toml version 0.1.0, against polyhook 0.1.11 (crates.io). Note this is a source build — steplock has never been published (see #186), so this cannot yet be reproduced against a released artifact. Worth fixing before the first release, since it affects the shipped default behavior.

Note the approve path is unaffected in practice: polyhook 0.1.11 emits {} for an approve either way, so this fix changes only the block responses.

Fix

One-line change in run_app to read via polyhook::read_from. PR to follow.


Filed automatically by the moadim routine "Nightly lib dogfood → issue + PR (polyhook, block-no-verify, steplock)".

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions