Skip to content

Commit 12fd144

Browse files
committed
fix(runtime): derive the tool permission mode from the live boundary (#3349)
The header carries the permission mode the backend was composed with, and a backend generation outlives many turns. A permission change does not recompose it, so `ctx.permissionMode` stayed at whatever the mode was when the backend was built while the boundary the same dispatch reads for sandboxing had already moved. The picker said Bypass, Bash stayed sandboxed, and approvals kept prompting. The boundary is the authority, so the mode is read off the boundary this dispatch is about to run against. The header answers only for an externally isolated boundary, which projects to no local mode at all. Plan mode writes only the header, never the boundary, so the collaboration overlay still has to apply on top; deriving purely from the boundary would turn plan+managed from explore into ask and open the client-capability gate. That rule now lives in @maka/core because both the composer and tool dispatch have to reach the same answer, and packages/runtime cannot reach runtime-host.
1 parent 9225f80 commit 12fd144

4 files changed

Lines changed: 128 additions & 11 deletions

File tree

packages/core/src/collaboration.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,28 @@
1717
* under the License.
1818
*/
1919

20+
import type { PermissionMode } from './permission.js';
21+
2022
export const COLLABORATION_MODES = ['agent', 'plan'] as const;
2123

2224
export type CollaborationMode = (typeof COLLABORATION_MODES)[number];
2325

2426
export function isCollaborationMode(value: unknown): value is CollaborationMode {
2527
return typeof value === 'string' && (COLLABORATION_MODES as readonly string[]).includes(value);
2628
}
29+
30+
/**
31+
* The permission mode a session runs under once its collaboration mode is
32+
* applied: Plan mode holds the session to read-only unless it is on Bypass.
33+
*
34+
* Lives here because both the model composer and tool dispatch have to reach
35+
* the same answer; a second copy of the rule is a second authority.
36+
*/
37+
export function resolveCollaborationPermissionMode(input: {
38+
readonly collaborationMode: CollaborationMode;
39+
readonly permissionMode: PermissionMode;
40+
}): PermissionMode {
41+
return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass'
42+
? 'explore'
43+
: input.permissionMode;
44+
}

packages/runtime-host/src/server/execution-model-composition.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { relayModelProfile } from '@maka/core/model-thinking';
2424
import type { ModelCallAttempt } from '@maka/core/model-call-attempt';
2525
import type { ModelCallCommit } from '@maka/core/agent-run';
2626
import type { PermissionMode } from '@maka/core/permission';
27+
import { resolveCollaborationPermissionMode } from '@maka/core/collaboration';
2728
import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend';
2829
import {
2930
buildDefaultContextBudgetPolicy,
@@ -535,11 +536,4 @@ class HostAiSdkBackend extends AiSdkBackend {
535536
}
536537
}
537538

538-
export function resolveCollaborationPermissionMode(input: {
539-
readonly collaborationMode: 'agent' | 'plan';
540-
readonly permissionMode: PermissionMode;
541-
}): PermissionMode {
542-
return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass'
543-
? 'explore'
544-
: input.permissionMode;
545-
}
539+
export { resolveCollaborationPermissionMode };

packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,90 @@ describe('ToolRuntime session sandbox boundary', () => {
103103
);
104104
});
105105

106+
// #3349: the header carries the mode the backend was built with. A picker
107+
// switch to Bypass between two turns widens the boundary without rebuilding
108+
// that header, so a dispatch that trusts the header keeps sandboxing and
109+
// keeps prompting while the picker already reads Bypass.
110+
test('reads the permission mode off the live boundary, not the header it was built with', async () => {
111+
let boundary: ExecutionBoundary = {
112+
kind: 'managed',
113+
profile: createWorkspaceWritePermissionProfile(),
114+
revision: 0,
115+
};
116+
const observed: Array<{ kind: string; permissionMode: string | undefined }> = [];
117+
const runtime = new ToolRuntime({
118+
turnId: 'turn-1',
119+
sessionId: 'session-1',
120+
header: header(),
121+
connection: { providerType: 'openai', slug: 'test' } as never,
122+
modelId: 'test',
123+
appendMessage: async () => {},
124+
readExecutionBoundary: async () => boundary,
125+
newId: nextId(),
126+
now: () => 1,
127+
getPermissionPauseTarget: () => null,
128+
});
129+
const tool: MakaTool = {
130+
name: 'Bash',
131+
description: 'test',
132+
parameters: {},
133+
impl: (_args, context) => {
134+
assert.ok(context.executionBoundary);
135+
observed.push({
136+
kind: context.executionBoundary.kind,
137+
permissionMode: context.permissionMode,
138+
});
139+
return { ok: true };
140+
},
141+
};
142+
143+
await settle(runtime, tool, 'tool-1');
144+
boundary = { kind: 'bypass', revision: 1 };
145+
await settle(runtime, tool, 'tool-2');
146+
147+
assert.equal(header().permissionMode, 'ask');
148+
assert.deepEqual(observed, [
149+
{ kind: 'managed', permissionMode: 'ask' },
150+
{ kind: 'bypass', permissionMode: 'bypass' },
151+
]);
152+
});
153+
154+
test('holds Plan mode to read-only even when the live boundary allows writes', async () => {
155+
let observed: string | undefined;
156+
const runtime = new ToolRuntime({
157+
turnId: 'turn-1',
158+
sessionId: 'session-1',
159+
header: { ...header(), collaborationMode: 'plan' },
160+
connection: { providerType: 'openai', slug: 'test' } as never,
161+
modelId: 'test',
162+
appendMessage: async () => {},
163+
readExecutionBoundary: async () => ({
164+
kind: 'managed',
165+
profile: createWorkspaceWritePermissionProfile(),
166+
revision: 0,
167+
}),
168+
newId: nextId(),
169+
now: () => 1,
170+
getPermissionPauseTarget: () => null,
171+
});
172+
173+
await settle(
174+
runtime,
175+
{
176+
name: 'Bash',
177+
description: 'test',
178+
parameters: {},
179+
impl: (_args, context) => {
180+
observed = context.permissionMode;
181+
return { ok: true };
182+
},
183+
},
184+
'tool-1',
185+
);
186+
187+
assert.equal(observed, 'explore');
188+
});
189+
106190
test('parks the dedicated tool and admits only one boundary request at a time', async () => {
107191
const events: SessionEvent[] = [];
108192
const managed: ExecutionBoundary = {

packages/runtime/src/tool-runtime.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema';
2121
import { projectAgentSwarmResult } from '@maka/core/agent-swarm';
2222
import { projectToolActivityArgs } from '@maka/core/tool-activity-args';
23+
import { resolveCollaborationPermissionMode } from '@maka/core/collaboration';
2324
import {
2425
type CreateSandboxBoundaryRequest,
2526
type ExecutionBoundary,
27+
executionBoundaryDisplayMode,
2628
type SandboxBoundaryDecision,
2729
type SandboxBoundaryExpansion,
2830
type SandboxBoundaryRequest,
@@ -599,6 +601,24 @@ export class ToolRuntime {
599601
this.readExecutionBoundary = input.readExecutionBoundary;
600602
}
601603

604+
/**
605+
* The permission mode in force for this dispatch.
606+
*
607+
* The header carries the mode this backend was built with, which goes stale
608+
* the moment the boundary widens under a live Session. The boundary is the
609+
* authority, so read the mode off the boundary we are about to dispatch
610+
* against; the header only answers for an externally isolated boundary,
611+
* which projects to no local mode at all.
612+
*/
613+
private livePermissionMode(boundary: ExecutionBoundary): PermissionMode {
614+
const displayed = executionBoundaryDisplayMode(boundary);
615+
if (displayed === undefined) return this.input.header.permissionMode;
616+
return resolveCollaborationPermissionMode({
617+
collaborationMode: this.input.header.collaborationMode ?? 'agent',
618+
permissionMode: displayed,
619+
});
620+
}
621+
602622
async endTurn(reason: 'completed' | 'aborted' = 'completed'): Promise<void> {
603623
const turnId = this.turnId;
604624
const boundaryRequests = this.sandboxBoundaryRequests.entries();
@@ -1434,7 +1454,8 @@ export class ToolRuntime {
14341454
}
14351455
const admissionFailure = !tool.prepareExecution
14361456
? CLIENT_CAPABILITY_PREPARATION_MESSAGE
1437-
: clientCapabilityBoundary.kind !== 'bypass' && this.input.header.permissionMode !== 'ask'
1457+
: clientCapabilityBoundary.kind !== 'bypass' &&
1458+
this.livePermissionMode(clientCapabilityBoundary) !== 'ask'
14381459
? CLIENT_CAPABILITY_BOUNDARY_MESSAGE
14391460
: undefined;
14401461
if (admissionFailure) {
@@ -1463,7 +1484,7 @@ export class ToolRuntime {
14631484
...(runId ? { runId } : {}),
14641485
cwd: this.input.header.cwd,
14651486
executionBoundary: clientCapabilityBoundary,
1466-
permissionMode: this.input.header.permissionMode,
1487+
permissionMode: this.livePermissionMode(clientCapabilityBoundary),
14671488
toolCallId: toolUseId,
14681489
abortSignal: ctx.abortSignal,
14691490
});
@@ -1594,7 +1615,7 @@ export class ToolRuntime {
15941615
: {}),
15951616
cwd: this.input.header.cwd,
15961617
executionBoundary,
1597-
permissionMode: this.input.header.permissionMode,
1618+
permissionMode: this.livePermissionMode(executionBoundary),
15981619
toolCallId: toolUseId,
15991620
// The id the call event actually carries, not the candidate: by here
16001621
// `prepareDurableToolAttempt` has pushed it on the dispatch lane.

0 commit comments

Comments
 (0)