Skip to content

Security: fail closed when the conversation loop body throws - #487

Open
lezama wants to merge 9 commits into
Automattic:mainfrom
lezama:fix/conversation-loop-fail-closed-on-throw
Open

Security: fail closed when the conversation loop body throws#487
lezama wants to merge 9 commits into
Automattic:mainfrom
lezama:fix/conversation-loop-fail-closed-on-throw

Conversation

@lezama

@lezama lezama commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Fail closed across the full conversation runtime, not only the provider turn boundary.

  • Acquire transcript ownership before creating loop-owned run state. Lock contention returns a canonical failed, retryable result with lock-busy diagnostics, zero provider work, and no transcript mutation.
  • The public claimed-run boundary uses that failed outcome to terminalize a preclaimed run instead of preserving a running zombie that cannot be retried.
  • Treat throwing or malformed pre-tool policy decisions as failures. Invalid pending runtime requests and replace_result payloads throw before execution, produce zero tool effects, persist failed audit state, and durably fail run control.
  • Checkpoint a non-provider-facing tool_effect_completed receipt immediately after execution, while mediation remains responsible for the single outward response.
  • Approval-required execution persists one approval envelope and no provisional raw tool response.
  • Require terminal run-control writes to return the requested canonical stored run; null, malformed, or mismatched records throw retryably.
  • Treat transcript persistence as part of terminal publication. Persistence failure emits no completed event/result, checked-finalizes run control to failed, and preserves the original storage throwable as retryable diagnostics.

Coverage

Focused coverage verifies public claimed-run lock contention, direct lock contention, malformed pending/replacement policies, policy/runtime-store/hook/truncator throws, effect receipts, approval single-response behavior, contract violations, terminal storage failures, transcript persistence failures, and exactly-once provider/effect execution.

Verification

  • composer test
  • composer phpstan
  • git diff --check

All checks pass locally. Composer emits host-package PHP deprecation notices before test output, but the repository suite completes successfully.

lezama and others added 3 commits August 3, 2026 11:34
`WP_Agent_Conversation_Loop::run()` wrapped only the turn-runner call in a
try/catch that finalizes the run to STATUS_FAILED, persists the transcript,
and emits a `failed` event. The rest of the outer loop body -- post-turn
checks, tool-call mediation, message construction, the runtime-tool store,
and the caller-supplied `should_continue` continuation policy -- ran under an
outer try whose only companion was a `finally` that released the transcript
lock. There was no catch.

An unguarded `\Throwable` from that region (e.g. `WP_Agent_Tool_Call::normalize`,
message construction, the runtime-tool store, or a throwing `should_continue`)
escaped `run()` with the run stuck in STATUS_RUNNING forever: no `failed`
event, no persisted transcript, and the transcript lock as the only thing the
`finally` cleaned up.

Fail closed. Factor the existing failure finalization (emit `failed`,
finish_run -> FAILED, persist transcript, return the normalized failure result)
into a shared `finalize_loop_failure()` helper, and add an outer catch that
runs it for any unguarded throw from the loop body, matching the turn-runner
boundary exactly. The `finally` still releases the lock. The deliberate
turn-runner contract violation (non-array return) is finalized in place and
re-thrown to the caller as before; the guard detects it by object identity so
it keeps escaping instead of being converted into a structured failure.

Adds tests/conversation-loop-fail-closed-smoke.php: a throw from the outer
body finalizes to FAILED, emits a `failed` event, and calls the transcript
persister (fails without this change); the normal continuation path still
completes cleanly; and the non-array contract violation still escapes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chubes4
chubes4 marked this pull request as ready for review August 26, 2026 21:58
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.

2 participants