Skip to content

Permission mode switch (Auto→Bypass) is not honored by the next turn #3349

Closed

Description

@Astro-Han

Switching the per-session permission mode from Auto to Bypass is not honored by the next turn. This reproduces in both a plain chat session (no Goal) and a running task. After the switch, the next turn still executes under the managed/ask boundary: Bash is still sandboxed and the model is still prompted for approval, even though the picker shows Bypass. The boundary the next turn actually runs under is not refreshed to the newly committed configuration before it executes.

Expected: a permission change is observed by the next turn that starts after it, before that turn's first tool call.

How to reproduce

  1. In any session (a plain chat, no Goal required), switch the permission picker from Auto to Bypass.
  2. Send / continue so a new turn runs; issue a Bash command or a filesystem write.
  3. Observed: the command is still sandboxed / the model is still asked to approve; the turn still runs under the managed boundary.

Note: more frequent on long-running/multi-turn tasks — under an active Goal the continuation keeps the session claim busy, which widens the failure window — but it also reproduces in a plain session, so this is not Goal-specific.

Environment

  • Maka version or commit: 98bdd6cf8 (main)
  • OS and version: macOS 26.5.2
  • Surface: Desktop
  • Node.js version, if running from source: v26.7.0

Logs, screenshots, or additional context

Two code-traced observations, offered as evidence rather than as a design:

  • Tools decide sandboxing/approval from the executionBoundary they read at call time (packages/runtime/src/tool-runtime.ts readExecutionBoundary; packages/runtime/src/builtin-tools.ts boundary.kind === 'bypass'), but ctx.permissionMode is a separate value frozen into the backend header when the backend is composed (tool-runtime.ts this.input.header.permissionMode; execution-model-composition.ts). A backend generation is reused across turns and a permission change does not recompose it, so the frozen value never catches up.
  • Under an active Goal the switch can also be refused before it commits: setPermissionMode bails on hasActiveRuns, and a continuation holds a successor claim near-continuously.

Please treat the existing quiescence requirement as load-bearing until proven otherwise, rather than as part of the defect. Requiring the session to be quiescent is what lets a narrowing change (Bypass→Ask) terminate lineage shells and settle boundary requests without any additional machinery; removing it obliges whoever removes it to rebuild those guarantees by hand. The two directions are not symmetric — a widening grant cannot over-authorize anyone, because every consumer holding a stale value fails closed against a wider boundary and a descendant's admission check only gets easier.

Acceptance is the property above, verified by regression tests for the reporter's cases: a plain session where the switch lands between two turns, and a session under an active Goal. The implementation is open.

Activity

  1. chinawch007 commented on Aug 20, 2026

    @chinawch007
    Contributor

    I'd like to claim this issue. I've verified the root-cause trace against current main and have a complete-fix implementation plan (single PR) below. Feedback welcome — especially on the three open decision points at the end.

    Root-cause confirmation

    The reporter's code trace checks out, with two refinements after reading current main:

    1. The dominant failure mode is the eager bail, not a lost commit. runSessionQuiescentMutation (packages/runtime/src/runtime-kernel.ts:478-487) rejects with SessionQuiescentMutationBusyError whenever any execution claim exists — it never queues and waits. Under an active Goal, claims are effectively continuous (the coordinator admits the successor turn immediately on settlement), so a switch attempted mid-goal is virtually always rejected as session_busy, or has to win a millisecond-scale inter-turn window. That matches the reported intermittency.
    2. A switch that does commit is observed by the next kernel turn — the successor claim's admission barrier serializes it behind mutations already enqueued on the per-session tail, and commitExecutionResourceTransition disposes backends before commit (packages/runtime/src/session-manager.ts:1748-1752). What is real today is the half-applied state inside an already-running turn: readExecutionBoundary is read live per tool call (packages/runtime/src/tool-runtime.ts:1304) while ctx.permissionMode is frozen at backend build time (tool-runtime.ts:1314). That split explains the mixed symptoms (sandbox lifted, approvals still prompted).

    One latent amplifier worth fixing while we're here: sessionConfigurationMatches (packages/runtime-host/src/server/session-catalog-coordinator.ts:770-785) short-circuits to committed by comparing header fields only — a header/boundary divergence would be silently preserved instead of repaired.

    Implementation plan (complete fix, single PR)

    Three pillars.

    A. Single read model — the boundary is the authority

    Follow #1611 to its conclusion: derive ctx.permissionMode live from the execution boundary at each tool dispatch (via executionBoundaryDisplayMode()) instead of passing the header snapshot frozen at backend build. Migration points: tool-runtime.ts:1314, the filesystemCall fallback (builtin-tools.ts:585), the composer freeze (runtime-host/src/server/execution-model-composition.ts:322, preserving the plan-mode interplay), and the subagent snapshot (runtime-kernel.ts:2887).

    No storage schema migration is needed: the store already commits header + boundary atomically (packages/storage/src/sqlite-session-metadata-store.ts:678-708) and the boundary carries a monotonic revision, so this is a read-side consolidation.

    B. Queued quiescent commit — replace the eager bail

    New kernel primitive runSessionQueuedQuiescentMutation(sessionIds, operation):

    1. Synchronously reserve a slot on each session's mutation tail, so any claim created afterwards captures the slot in its admission barrier;
    2. Wait for attached (running) claims to drain — a new claim-drain notification fired from releaseExecutionClaim;
    3. Run the existing commit logic, then release the slot.

    Deadlock-freedom argument: the slot waits only for attached claims, and attached claims never wait on the tail (they have already passed their barrier); claims created after the reservation are barrier-blocked on the slot itself. The waited-on set and the waiting-on-the-slot set are disjoint, so no cycle is possible. Worst-case delay is one turn.

    transitionSessionConfiguration, setPermissionMode and setExecutionBoundaryKind move to this primitive; the internal hasActiveRuns re-checks come out (quiescence becomes the kernel's single authority). relocateSessionWorkspace keeps its fail-fast behavior; the waiting_for_user rejection is kept as an explicit error (decision point D1).

    Net semantics: a switch requested mid-turn commits in the inter-turn gap, before the successor turn's first tool call — exactly the expected behavior stated in this issue. The goal-continuation coordinator itself needs no changes; its successor claims naturally serialize behind the reserved slot.

    C. Revision guard + catalog short-circuit fix (defense in depth)

    • BackendGeneration records the boundary revision it was built with; ensureActive rebuilds instead of reusing when the store's current revision differs. Any future write path that skips backend disposal self-heals within one turn, so the "all config writes must dispose" invariant stops being a convention and becomes a mechanism.
    • sessionConfigurationMatches additionally compares boundary consistency, so a divergent state gets repaired by the next update instead of being short-circuited as already-committed.

    Testing plan

    • Red→green regression (written first, verified failing on current main): scripted claim lifecycle — turn N attached → switch Auto→Bypass → settle N → successor turn; assert the successor's first tool call sees executionBoundary.kind === 'bypass' and ctx.permissionMode === 'bypass' (both assertions, to catch half-application).
    • Race matrix: switch timing × {mid-turn, evaluation gap, successor claim pending, waiting_for_user}.
    • Invariants: backend disposal never happens while a run is attached; timeout-based no-deadlock assertion; revision-guard self-heal test.
    • Direction regression: Bypass→Auto narrowing keeps its shell-run fencing/rollback; mid-turn approved sandbox expansions are unaffected.
    • Seeded randomized stress: concurrent switches vs. goal continuation, asserting "any committed switch is observed by every turn that starts after it".
    • Existing assertions that expect session_busy on these paths are updated to the queued semantics — an intentional behavior change.

    Scope and shape

    Single PR, roughly 800–900 lines including tests, landed as a commit series: (1) kernel primitive + unit tests, (2) session-manager wiring + red→green regression, (3) read-model consolidation, (4) revision guard, (5) race matrix + stress + assertion updates.

    Open decision points

    • D1 — keep rejecting switches while waiting_for_user (recommended: the user is mid permission decision) or queue them as well?
    • D2 — deriving permissionMode from the boundary removes the legacy 'execute' value from ctx.permissionMode. I'll audit consumers; flagging in case anything depends on it.
    • D3 — the switch RPC may now wait up to one turn before returning (truthful "committed" semantics); the picker already has a pending state. Fine as-is, or do we want an explicit queued outcome code?

    I will start when this issue is assigned to me.

  2. Astro-Han commented on Aug 20, 2026

    @Astro-Han
    ContributorAuthor

    Assigned!

  3. changed the title [-]Permission mode switch (Auto→Bypass) is not honored by the next turn of an active Goal[/-] [+]Permission mode switch (Auto→Bypass) is not honored by the next turn[/+] on Aug 21, 2026
  4. Astro-Han commented on Aug 21, 2026

    @Astro-Han
    ContributorAuthor

    @chinawch007 — a scope correction from the reporter: this also reproduces in a plain chat session with no Goal. Switching Auto → Bypass between two user-initiated turns, the next turn still runs under the managed/ask boundary (Bash still sandboxed, approvals still prompted). The Goal case is the most frequent trigger because continuation keeps the session claim busy, but the bug is not Goal-specific — the picker-to-next-turn path itself does not observe the new boundary.

    Your two-part fix (queued quiescent commit + deriving permissionMode from the boundary) is session-level anyway, so it should cover both. One ask: add a plain-session case to the regression/race matrix — switch between two ordinary turns, no Goal, and assert the successor turn's first tool call sees executionBoundary.kind === 'bypass' and ctx.permissionMode === 'bypass'. That keeps a Goal-scoped fix from closing the issue while the non-Goal path stays broken.

    I've updated the issue title/body to drop the "active Goal" scoping.

  5. chinawch007 commented on Aug 21, 2026

    @chinawch007
    Contributor

    Thanks for the scope correction — understood: it's the picker-to-next-turn observability gap in general, and Goal continuation is just the most frequent trigger via claim continuity.

    Two adjustments on my side:

    1. The plain no-Goal case (switching between two ordinary turns) is now the primary red→green regression scenario, asserting the successor turn's first tool call sees both executionBoundary.kind === 'bypass' and ctx.permissionMode === 'bypass', as you suggested; the Goal case stays as a second scenario in the race matrix.
    2. Since an idle-session switch has no claim race to lose, the plain repro hints at an additional mechanism beyond the eager session_busy bail — so the regression will also assert intermediate state (boundary revision bump, backend rebuild, whether the catalog short-circuit fires) to pin down exactly where the plain path diverges. I'll report the findings back here before landing the fix.

    The fix design itself is session-level as you noted, so the architecture is unchanged.

  6. 235 remaining items

  7. added a commit that references this issue on Sep 12, 2026
    d5d7816
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions