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.rs — run_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)".
Summary
steplockserializes every hook response in the legacy Claude Code shape, regardless of which agent actually invoked it, because the CLI parses stdin withpolyhook::parse::parse_eventinstead of reading it throughpolyhook::read_from.polyhook_corerecords the detected caller and event type in thread-locals during the read, andpolyhook::respondreads them back to pick the right wire format:core/src/bin/main.rs::run_appbypasses that read path entirely:https://github.com/polyhook/steplock/blob/f33cabd/core/src/bin/main.rs#L184-L199
So at
polyhook::respondtime the context is still the defaultcaller = Unknown, event = None, which routes toserialize_claude_code(resp, None)→is_pre_tool_use == false→ the legacy top-leveldecision: "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, whereashookSpecificOutput.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 gatedgit 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/:Claude Code payload
decision: "block"— terminates the Claude Code session.{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}Cline payload
{"approved":false,"reason":"..."}Windsurf payload
{"allow":false,"reason":"..."}polyhook correctly detects all three callers —
polyhook_to_hook_eventeven copiescalleronto steplock's ownHookEvent, and the existingpolyhook_event_maps_correctlytest assertsevent.caller == "claude-code". The caller is known; it is just discarded before serialization.Why the test suite doesn't catch it
Every existing
run_apptest 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.rs—run_app_blocks_matching_command,run_app_approves_non_matching_commandVersion tested
Repo
main@f33cabd,core/Cargo.tomlversion0.1.0, againstpolyhook0.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:
polyhook0.1.11 emits{}for an approve either way, so this fix changes only the block responses.Fix
One-line change in
run_appto read viapolyhook::read_from. PR to follow.Filed automatically by the moadim routine "Nightly lib dogfood → issue + PR (polyhook, block-no-verify, steplock)".