Skip to content

Keep each session handshake's own failure posture when hooks resolve in parallel - #180

Merged
rocklambros merged 1 commit into
integrationfrom
fix/resolve-session-config-race
Sep 22, 2026
Merged

rocklambros merged 1 commit into
integrationfrom
fix/resolve-session-config-race

Conversation

@rocklambros

Copy link
Copy Markdown
Collaborator

resolveSessionConfig() read the session store twice: once to decide whether to negotiate, and again after the handshake to choose what to return. Claude Code runs hooks in parallel and every hook in a session shares one store, so a second resolution could publish its own config between those reads. The first resolution then returned the other exchange's config, including its on_decision_failure. A step whose own handshake negotiated deny could fail by proceed if its decision delivery later failed. Reported privately.

The store is now read once, before anything awaits. A config already stored ends the call as before, with no handshake. Otherwise the config this exchange negotiated wins, including one it negotiated but could not store, which is what the function's existing comment already promised: the value in hand is authoritative for the step that negotiated it.

The store gets a second look only when this exchange negotiated nothing, for example when its own ServerHello was unusable. A config another resolution stored from the same Guardian is a better posture than the ACS default there. Returning only this exchange's value would have closed the race and failed open in that case instead.

Two tests in packages/host-adapter/test/client.test.ts pin both halves against a real handshake and a real memory store:

  • An overlapping resolution publishes proceed right after this exchange stores deny. The call must return deny. It failed on the old code with proceed.
  • This exchange's ServerHello is unusable while another resolution stores deny. The call must return deny. It passes on the old code and failed when the last line was changed to return only this exchange's value, so it guards the fallback.

bun run typecheck is clean and bun test gives 1112 pass, 1 skip, 0 fail across 1113 tests. The root README said the suite has 1,111 tests, which these two made false, so it now drops the total and keeps the count that matters: without trash on PATH, 26 tests still fail, verified by running the suite with it hidden. uv run pytest gives 278 passed, 1 skipped, and uv run mkdocs build --strict is clean.

Signed-off-by: Rock Lambros rock@rockcyber.com

…in parallel

`resolveSessionConfig()` read the session store twice: once to decide whether to negotiate, and again after the handshake to choose what to return. Claude Code runs hooks in parallel and every hook in a session shares one store, so a second resolution could publish its own config between those reads. The first resolution then returned the other exchange's config, including its `on_decision_failure`. A step whose own handshake negotiated `deny` could fail by `proceed` if its decision delivery later failed. Reported privately.

The store is now read once, before anything awaits. A config already stored ends the call as before, with no handshake. Otherwise the config this exchange negotiated wins, including one it negotiated but could not store, which is what the function's existing comment already promised: the value in hand is authoritative for the step that negotiated it.

The store gets a second look only when this exchange negotiated nothing, for example when its own ServerHello was unusable. A config another resolution stored from the same Guardian is a better posture than the ACS default there. Returning only this exchange's value would have closed the race and failed open in that case instead.

Two tests in `packages/host-adapter/test/client.test.ts` pin both halves against a real handshake and a real memory store:

- An overlapping resolution publishes `proceed` right after this exchange stores `deny`. The call must return `deny`. It failed on the old code with `proceed`.
- This exchange's ServerHello is unusable while another resolution stores `deny`. The call must return `deny`. It passes on the old code and failed when the last line was changed to return only this exchange's value, so it guards the fallback.

`bun run typecheck` is clean and `bun test` gives 1112 pass, 1 skip, 0 fail across 1113 tests. The root README said the suite has 1,111 tests, which these two made false, so it now drops the total and keeps the count that matters: without `trash` on `PATH`, 26 tests still fail, verified by running the suite with it hidden. `uv run pytest` gives 278 passed, 1 skipped, and `uv run mkdocs build --strict` is clean.

Signed-off-by: Rock Lambros <rock@rockcyber.com>
@github-actions

This comment has been minimized.

@rocklambros
rocklambros merged commit 27799c2 into integration Sep 22, 2026
9 checks passed
@rocklambros
rocklambros deleted the fix/resolve-session-config-race branch September 22, 2026 03:41
@rocklambros rocklambros removed the status:needs-triage Not yet triaged. Applied by the issue forms label Sep 22, 2026
@github-project-automation github-project-automation Bot moved this from Needs triage to Done in ACS Project Tracker Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant