Skip to content

Adopt the reactor approval-suspend primitive for permissions - #829

Merged
TheGreatAxios merged 9 commits into
mainfrom
cl-5699-adopt-the-reactors-approval-suspend-primitive-in
Sep 8, 2026
Merged

Adopt the reactor approval-suspend primitive for permissions#829
TheGreatAxios merged 9 commits into
mainfrom
cl-5699-adopt-the-reactors-approval-suspend-primitive-in

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Stage 5 of the Interchange vendoring sequence; implements the suspend design decided in the ask-authz RFC. Stacked on the four-package vendor branch.
  • The main-session permission gate no longer parks ask-tier tool calls on in-memory resolve closures. Its decision logic lives in one non-blocking decide() inside src/permission/gate.ts with two consumers:
    • evaluate() — the tool-runner middleware path, unchanged for consumers that do not run the reactor authz seam (sub-agents).
    • authorizeCall() — bridged into env.authorize by the new src/permission/reactor-authorize.ts. An ask there returns the upstream suspend effect: the vendored hook mints the correlationId, persists a PendingOperation (with suspendedCall + approvalSnapshot) through the context store's existing pendingOperations surface, and agent.send() settles as { type: "suspended", correlationId }.
  • Resume reaches the reactor on its reserved correlation signal channel: src/session/approval-resume.ts rebuilds the operator-facing PermissionRequest from the persisted snapshot, resolves it through the same requestApproval seam the TUI gate-wire overlay already uses (the assembleSessionGate seam, unchanged shape), and delivers an ApprovalDecision message with header interchangeCorrelationId. Approved → the reactor grants a one-shot bypass and re-dispatches the exact parked call; rejected → upstream answers it with a denied by approver error result. The director's declined-tool-result detection (isOperatorDeclinedToolResult) recognizes both flows' texts. The middleware/MCP gateToolCall bypasses the gate when the session is reactor-gated, so an approved re-dispatch never re-asks.
  • One vendored patch, ledgered: vendor/intx-inference/src/authz-extension.ts passes the frozen ToolCall as the authorize callback's Ctx instead 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 as authz-ts-authorize-call-context, promotion candidate; verified against the pristine SHA via bin/vendor-patch-diff.
  • Where the two Corbits-only policies live now:
    • Headless denial (was gate-internal !interactive blocks on the prompt path): decided inside decide() and surfaced as a deny effect → upstream block. Same trigger conditions; no suspension ever parks.
    • Stricter chained-command hard-deny (runShellAuthzBlockReason via preGrantGuardReason, plus the secret/restricted segment guards): unchanged, still owned by gate.ts as the pre-grant sequence, evaluated inside decide() before any grant can match; surfaces as a block.
  • Timeout stays owned by the reactor, as upstream: the hook's one-hour default (DEFAULT_APPROVAL_TIMEOUT_MS / timeoutAt) drives the gate timeout, and the persisted absolute deadline rehydrates across restart with the remaining time.
  • Known deviation (deliberate, per design discussion): model-facing deny text is now the hook's generic Denied by policy: tool:<name>/invoke rather 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.ts still wires permissiveAuthorize() + middleware gating; snapshot-and-restore of pending approvals across restart.

Behavior preservation, old vs new:

Scenario Before After
ask-tier call, operator approves closure awaited modal, tool ran suspends on correlationId, modal via same seam, one-shot bypass re-dispatch, no re-ask
operator rejects Blocked by permission policy: Operator declined: denied by approver: <message> (history), director still classifies it
headless (exec / non-interactive) gate denied with guidance text deny effect → block; no suspension parks, no surface raised
chained hard-deny (sudo rm -rf /etc) gate denied pre-grant same guard sequence inside decide() → block before any surface
sub-agents / non-reactor consumers middleware gate unchanged (evaluate path, isReactorGated() === false)

Verification

  • New tests/integration/reactor-approval-suspend.test.ts: suspend→approve→re-dispatch without re-ask (with reactor.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).
  • Failing-test-first evidence: with the vendored patch reverted to upstream's empty context, the three reactor-path tests fail (the bridge cannot see the call); with the patch, all pass. Existing closure-path suites (reactor-permission-multi-turn, permission, director, assemble-runtime) pass unchanged.
  • bun run check (lint + typecheck + build + projects-dir-guard + 6270 tests): exit 0.
  • Branch CI is green on the head commit: typecheck, eslint, prettier, and build-and-test all pass.

Closes CL-5699

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.
@linear-code

linear-code Bot commented Sep 7, 2026

Copy link
Copy Markdown

CL-5699

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
TheGreatAxios changed the base branch from cl-5698-vendor-intxagent-intxauthz-intxtools-posix-intxlog-at-interchange-head to main September 8, 2026 03:17
@TheGreatAxios TheGreatAxios changed the title Adopt the reactor approval-suspend primitive for main-session permissions Adopt the reactor approval-suspend primitive for permissions Sep 8, 2026
@TheGreatAxios
TheGreatAxios merged commit befd158 into main Sep 8, 2026
5 checks passed
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.

1 participant