fix(approval): fail closed when deliberation gate runs unscoped - #57
Merged
Conversation
When no deliberation scope is bound, deliberation_gate stored generation 0 and never released the one-shot, and the prior design would have auto-approved an unscoped destructive retry — fail-open on a security gate. The scope-less branch is unreachable in production (every tool runs via pythinker_core.step inside deliberation_scope, inherited through asyncio.create_task's context copy; other step callers use empty/deny-all toolsets), so the robust choice is to make the fallback explicit rather than silently degrade. Reaching it now fails CLOSED: always bounce a destructive call that cannot be proven deliberated, log a warning (it signals a wiring bug), and stop polluting deliberated_fingerprints. Add a regression test pinning the fail-closed contract. Refs: .pythinker/reports/deep-code-scan-deliberation-turn-boundary.md
Contributor
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthrough
ChangesFail-Closed Behavior for Unscoped Destructive Calls
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Augments the existing auto-deliberation entry to record that the gate fails closed when a destructive action is evaluated without a turn context. Regenerated the docs copy via docs/scripts/sync-changelog.mjs. Satisfies the changelog-entry-required check on this PR.
This was referenced Jun 2, 2026
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.
Summary
Follow-up to #56 (squash-merged). That PR scoped the auto-deliberation one-shot to
(execution context, generation)but left the scope-less fallback inApproval.deliberation_gatestoringgeneration = 0, where an identical destructive retry could never release the one-shot — and, more importantly, the prior design would have auto-approved an unscoped destructive retry, i.e. fail-open on a security gate.This change makes the scope-less branch fail closed and explicit instead of silently degrading.
What changed
deliberation_scopeis bound,deliberation_gatenow always bounces a destructive call (never auto-approves one it cannot prove was deliberated), logs awarning(reaching this path signals a wiring bug), and no longer pollutesdeliberated_fingerprintswith a deadunscoped::…: 0entry.test_unscoped_destructive_calls_always_bounce_fail_closed).Why this is the robust choice
The scope-less branch is unreachable in production: every real-tool path enters via
pythinker_core.step()wrapped indeliberation_scope, and tool futures inherit it throughasyncio.create_task's context copy; the only otherstep()callers (btw, compaction, blind-advisor) use empty/deny-all toolsets, sorequest()cannot fire there. Given that, the safe default for a destructive-action gate with no turn-boundary signal is fail-closed, and the right fix is to make the contract explicit rather than reopen a fail-open hole. A hardassert/raiseon this runtime approval path would be more fragile, not more robust.Verification
tests/core/test_approval_auto.py— passing (incl. new regression test).ruff check+ruff format --check— clean.pythinker-codesuite was green on the source commit (4242 passed).Single commit, cherry-picked clean onto current
main.Summary by CodeRabbit
Bug Fixes
Tests
Documentation