Adopt the reactor approval-suspend primitive for permissions - #829
Merged
TheGreatAxios merged 9 commits intoSep 8, 2026
Merged
Conversation
The before-tool authz hook called authorize with an empty context object, so a deployment whose authorization policy needs arguments could not decide at that seam. Pass the frozen call itself as the context, matching the empty-context freeze hygiene; ambient state stays closure-captured. Ledgered in PATCHES.md as a promotion candidate.
…ions The main-session permission gate parked ask-tier tool calls on resolve closures held open by the gate-wire overlay, a mechanism built before the vendored reactor carried an approval-suspend primitive. The gate's decision logic now lives in one non-blocking decide() with two consumers: evaluate() (the middleware path sub-agents still use) and authorizeCall(), bridged into env.authorize by src/permission/reactor-authorize.ts. An ask there suspends the call as a PendingOperation keyed by the reactor-minted correlationId; send() settles as suspended, and src/session/approval-resume.ts rebuilds the operator request from the persisted approval snapshot, resolves it through the existing requestApproval seam (TUI overlay unchanged), and delivers the decision on the correlationId signal channel. Approved decisions grant the one-shot bypass and the exact parked call re-dispatches without re-asking; rejected ones answer the call with an error result. Middleware and MCP gating bypass the gate under reactor gating so the resume never double-prompts. The two Corbits-only deny paths keep their behavior and move onto the same decision point: headless denial and the stricter chained-command hard-deny are decided inside decide() and surface as upstream block effects, whose model-facing text is the hook's generic reason rather than the gate's guard-specific wording. Timeouts stay owned by the reactor: the one-hour default deadline and restart rehydration come with the primitive.
Replace the em-dash string heuristic with named predicates that distinguish approver rejections from policy denies. A reason-bearing rejection re-infers so the model responds to the reason; a reason-less one keeps the canned decline; policy hard-denies are ordinary tool errors the model adapts to, as before the reactor adoption. Carry the gate's deny reason into the structured authz log, drop approval decisions that arrive after the reactor settled the correlation, and note the behavior change in the changelog.
TheGreatAxios
changed the base branch from
cl-5698-vendor-intxagent-intxauthz-intxtools-posix-intxlog-at-interchange-head
to
main
September 8, 2026 03:17
…ctors-approval-suspend-primitive-in # Conflicts: # CHANGELOG.md
…ctors-approval-suspend-primitive-in
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
decide()insidesrc/permission/gate.tswith two consumers:evaluate()— the tool-runner middleware path, unchanged for consumers that do not run the reactor authz seam (sub-agents).authorizeCall()— bridged intoenv.authorizeby the newsrc/permission/reactor-authorize.ts. Anaskthere returns the upstreamsuspendeffect: the vendored hook mints thecorrelationId, persists aPendingOperation(withsuspendedCall+approvalSnapshot) through the context store's existingpendingOperationssurface, andagent.send()settles as{ type: "suspended", correlationId }.src/session/approval-resume.tsrebuilds the operator-facingPermissionRequestfrom the persisted snapshot, resolves it through the samerequestApprovalseam the TUI gate-wire overlay already uses (theassembleSessionGateseam, unchanged shape), and delivers anApprovalDecisionmessage with headerinterchangeCorrelationId. Approved → the reactor grants a one-shot bypass and re-dispatches the exact parked call; rejected → upstream answers it with adenied by approvererror result. The director's declined-tool-result detection (isOperatorDeclinedToolResult) recognizes both flows' texts. The middleware/MCPgateToolCallbypasses the gate when the session is reactor-gated, so an approved re-dispatch never re-asks.vendor/intx-inference/src/authz-extension.tspasses the frozenToolCallas the authorize callback'sCtxinstead of an empty object. Upstream's docblock invites runtimes to choose the context shape, but the empty object made per-call identity unreachable (closure capture binds at build time, before the call exists), so argument-level policy could never decide at this seam. Ledgered asauthz-ts-authorize-call-context, promotion candidate; verified against the pristine SHA viabin/vendor-patch-diff.!interactiveblocks on the prompt path): decided insidedecide()and surfaced as a deny effect → upstreamblock. Same trigger conditions; no suspension ever parks.runShellAuthzBlockReasonviapreGrantGuardReason, plus the secret/restricted segment guards): unchanged, still owned bygate.tsas the pre-grant sequence, evaluated insidedecide()before any grant can match; surfaces as ablock.DEFAULT_APPROVAL_TIMEOUT_MS/timeoutAt) drives the gate timeout, and the persisted absolute deadline rehydrates across restart with the remaining time.Denied by policy: tool:<name>/invokerather than the gate's guard-specific wording; the suspended-approval modal still shows the full guard-specific request. Out of scope, noted for follow-up:src/subagent/run.tsstill wirespermissiveAuthorize()+ middleware gating; snapshot-and-restore of pending approvals across restart.Behavior preservation, old vs new:
Blocked by permission policy: Operator declined:denied by approver: <message>(history), director still classifies itsudo rm -rf /etc)decide()→ block before any surfaceevaluatepath,isReactorGated() === false)Verification
tests/integration/reactor-approval-suspend.test.ts: suspend→approve→re-dispatch without re-ask (withreactor.gate.blocked+ snapshot assertions), reject→error result via correlation delivery, headless deny→block, chained hard-deny→block with no surface, and authz-seam context tests (ToolCall identity in serial and parallel batches, fail-loud on a non-ToolCall context).reactor-permission-multi-turn, permission, director, assemble-runtime) pass unchanged.bun run check(lint + typecheck + build + projects-dir-guard + 6270 tests): exit 0.Closes CL-5699