Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/core/src/__tests__/sandbox-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import {
decodeExecutionBoundary,
executionBoundaryContains,
executionBoundaryDisplayMode,
executionBoundaryMatchesPermissionMode,
validateSandboxBoundaryExpansion,
} from '../sandbox-boundary.js';
import type { PermissionMode } from '../permission.js';
import {
canReadPath,
canWritePath,
Expand Down Expand Up @@ -73,6 +75,61 @@ describe('executionBoundaryDisplayMode', () => {
);
});

test('a widened read-only profile no longer matches explore (#3349)', () => {
const widened = applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), {
filesystem: { entries: [{ path: '/outside/dist', access: 'write', scope: 'subtree' }] },
});
const boundary = { kind: 'managed', profile: widened, revision: 1 } as const;

// The name stayed 'read-only' while the structure became writable: a
// no-op short-circuit keyed on this answer must not bless that divergence.
expect(executionBoundaryMatchesPermissionMode(boundary, 'explore')).toBe(false);
expect(executionBoundaryMatchesPermissionMode(boundary, 'ask')).toBe(true);
});

test('matching follows the structural derivation, not the profile name', () => {
const customReadOnly: PermissionProfileManaged = {
...createReadOnlyPermissionProfile(),
name: 'custom',
};
expect(
executionBoundaryMatchesPermissionMode(
{ kind: 'managed', profile: customReadOnly, revision: 0 },
'explore',
),
).toBe(true);
expect(
executionBoundaryMatchesPermissionMode(
{ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), revision: 0 },
'ask',
),
).toBe(true);
expect(executionBoundaryMatchesPermissionMode({ kind: 'bypass', revision: 0 }, 'bypass')).toBe(
true,
);
expect(
executionBoundaryMatchesPermissionMode(
{ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), revision: 0 },
'bypass',
),
).toBe(false);
});

test('a legacy persisted execute value never matches and an external boundary is not verifiable', () => {
// 'execute' left the mode vocabulary on main; a stale persisted value must
// still never match, so it always routes through a transition instead of
// being blessed as already-committed.
expect(
executionBoundaryMatchesPermissionMode(
{ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), revision: 0 },
'execute' as PermissionMode,
),
).toBe(false);
expect(executionBoundaryMatchesPermissionMode({ kind: 'external', revision: 0 }, 'ask')).toBe(
false,
);
});

test('under-states danger-full-access as Auto rather than naming a mode for it', () => {
// A deliberate collapse, NOT a description of this profile: the picker
// offers two modes and no third one is being invented for a profile the
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ export function isPermissionMode(value: unknown): value is PermissionMode {
return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value);
}

/**
* The permission mode a tool-facing consumer should act on: a plan-mode
* session presents read-only authority to tools unless it is bypassed. Shared
* by the runtime-host composer (build time) and the tool runtime (dispatch
* time), so both derive the same mode from the same inputs.
*/
export function resolveCollaborationPermissionMode(input: {
readonly collaborationMode: 'agent' | 'plan';
readonly permissionMode: PermissionMode;
}): PermissionMode {
return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass'
? 'explore'
: input.permissionMode;
}

/** Canonical category names use Claude SDK terminology. Pi adapter MUST
* translate Pi-native tool names into these before they reach the runtime. */
export type ToolCategory =
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/sandbox-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,24 @@ export function executionBoundaryDisplayMode(
return readOnly ? 'explore' : 'ask';
}

/**
* Whether the durable boundary already expresses the requested permission
* mode, derived through the same structural read (#1611) as the display mode:
* a read-only-named profile widened by an approved expansion no longer reads
* as explore. Callers that short-circuit a no-op configuration update on this
* answer must consult it: comparing the header's stored `permissionMode`
* alone would bless a header/boundary divergence as already-committed.
* Legacy 'execute' never matches — forcing the transition is the safe
* direction — and an external boundary is not locally verifiable.
*/
export function executionBoundaryMatchesPermissionMode(
boundary: ExecutionBoundary,
mode: PermissionMode,
): boolean {
const displayMode = executionBoundaryDisplayMode(boundary);
return displayMode !== undefined && displayMode === mode;
}

export function createGenesisExecutionBoundary(mode: PermissionMode): ExecutionBoundary {
if (mode === 'bypass') return { kind: 'bypass', revision: 0 };
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,79 @@ test('typed configuration rejection does not request Host drain', async () => {
assert.equal(fixture.drainRequests(), 0);
});

test('a no-op configuration update repairs a header/boundary divergence instead of blessing it', async () => {
// The header matches the requested configuration on every field, so only the
// boundary consistency check can tell a genuine no-op from a divergence that
// must be repaired through Runtime authority.
let transitions = 0;
const consistent = createFixture({
stores: {
readHeaderRecordSnapshot: async () => headerSnapshot(matchingHeader(['user-label']), 3),
readCatalogRecord: async () => catalogRecord(matchingHeader(['user-label']), 3),
readExecutionBoundary: async () => ({ kind: 'bypass', revision: 1 }),
},
manager: {
transitionSessionConfiguration: async () => {
transitions += 1;
return headerSnapshot(matchingHeader(['user-label']), 3);
},
},
});
const consistentOutcome = await consistent.coordinator.handlers['session.configuration.update'](
bypassConfigurationInput(consistent.sessionId, consistent.revision()),
context,
);
assert.equal(consistentOutcome.ok, true);
assert.equal(transitions, 0);

const divergent = createFixture({
stores: {
readHeaderRecordSnapshot: async () => headerSnapshot(matchingHeader(['user-label']), 3),
readCatalogRecord: async () => catalogRecord(matchingHeader(['user-label']), 3),
// The header says bypass while the durable boundary stays managed.
readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'),
},
manager: {
transitionSessionConfiguration: async () => {
transitions += 1;
return headerSnapshot(matchingHeader(['user-label']), 3);
},
},
});
const divergentOutcome = await divergent.coordinator.handlers['session.configuration.update'](
bypassConfigurationInput(divergent.sessionId, divergent.revision()),
context,
);
assert.equal(divergentOutcome.ok, true);
assert.equal(transitions, 1);
});

test('an externally isolated session keeps benign no-op updates on the header short-circuit', async () => {
let transitions = 0;
const fixture = createFixture({
stores: {
readHeaderRecordSnapshot: async () => headerSnapshot(matchingHeader(['user-label']), 3),
readCatalogRecord: async () => catalogRecord(matchingHeader(['user-label']), 3),
readExecutionBoundary: async () => ({ kind: 'external', revision: 0 }),
},
manager: {
transitionSessionConfiguration: async () => {
transitions += 1;
return headerSnapshot(matchingHeader(['user-label']), 3);
},
},
});
const outcome = await fixture.coordinator.handlers['session.configuration.update'](
bypassConfigurationInput(fixture.sessionId, fixture.revision()),
context,
);
// The external boundary is not locally verifiable, so the header comparison
// alone decides the no-op: the store would refuse to move an externally
// isolated boundary, and a benign re-apply must not become a failure.
assert.equal(outcome.ok, true);
assert.equal(transitions, 0);
});

test('creation rejects reserved execution labels before claiming a Session identity', async () => {
let createAttempts = 0;
const fixture = createFixture({
Expand Down Expand Up @@ -1311,6 +1384,22 @@ function configurationInput(
};
}

function bypassConfigurationInput(
sessionId: string,
expectedRevision: number,
): SessionConfigurationUpdateInput {
const base = configurationInput(sessionId, expectedRevision);
return { ...base, configuration: { ...base.configuration, permissionMode: 'bypass' } };
}

function matchingHeader(labels: readonly string[]): SessionHeader {
return {
...sessionHeader('session-1', labels),
permissionMode: 'bypass',
orchestrationMode: 'graph',
};
}

function sessionHeader(sessionId: string, labels: readonly string[]): SessionHeader {
return {
id: sessionId,
Expand Down
11 changes: 2 additions & 9 deletions packages/runtime-host/src/server/execution-model-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { resolveModelVisionSupport } from '@maka/core/model-metadata';
import { relayModelProfile } from '@maka/core/model-thinking';
import type { ModelCallAttempt } from '@maka/core/model-call-attempt';
import type { ModelCallCommit } from '@maka/core/agent-run';
import type { PermissionMode } from '@maka/core/permission';
import { resolveCollaborationPermissionMode } from '@maka/core/permission';
import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend';
import {
buildDefaultContextBudgetPolicy,
Expand Down Expand Up @@ -468,11 +468,4 @@ class HostAiSdkBackend extends AiSdkBackend {
}
}

export function resolveCollaborationPermissionMode(input: {
readonly collaborationMode: 'agent' | 'plan';
readonly permissionMode: PermissionMode;
}): PermissionMode {
return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass'
? 'explore'
: input.permissionMode;
}
export { resolveCollaborationPermissionMode } from '@maka/core/permission';
12 changes: 12 additions & 0 deletions packages/runtime-host/src/server/session-catalog-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { isModelExplicitlyUnsupportedForChat } from '@maka/core/model-catalog';
import { thinkingVariantsForConnection } from '@maka/core/model-thinking';
import {
executionBoundaryDisplayMode,
executionBoundaryMatchesPermissionMode,
type ExecutionBoundary,
type ExecutionBoundarySummary,
} from '@maka/core/sandbox-boundary';
Expand Down Expand Up @@ -470,8 +471,19 @@ export class HostSessionCatalogCoordinator {
input.configuration.thinkingLevel ?? undefined,
);
const clearsConnectionBlock = current.header.blockedReason === 'NO_REAL_CONNECTION';
// The boundary must match too: the header's stored permissionMode alone
// cannot bless a no-op, or a header/boundary divergence would be
// short-circuited as already-committed instead of repaired. An external
// boundary is not locally verifiable — the store refuses to move it into
// Auto or Bypass — so for those sessions the header comparison alone
// decides the no-op, keeping benign updates working.
const boundary = await this.#stores.readExecutionBoundary(input.sessionId);
const boundaryMatchesConfiguration =
boundary.kind === 'external' ||
executionBoundaryMatchesPermissionMode(boundary, input.configuration.permissionMode);
if (
!clearsConnectionBlock &&
boundaryMatchesConfiguration &&
sessionConfigurationMatches(current.header, model, input.configuration)
) {
return configurationSuccess({
Expand Down
Loading
Loading