Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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__/permission-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
createDangerFullAccessPermissionProfile,
createReadOnlyPermissionProfile,
createWorkspaceWritePermissionProfile,
isCanonicalReadOnlyPermissionProfile,
isDeniedPath,
isProtectedMetadataPath,
isReadOnlyPermissionProfile,
Expand Down Expand Up @@ -193,6 +194,62 @@ describe('isReadOnlyPermissionProfile', () => {
});
});

describe('isCanonicalReadOnlyPermissionProfile', () => {
test('matches the canonical policy without relying on its display name', () => {
const { name: _name, ...policy } = createReadOnlyPermissionProfile();
for (const profile of [
policy,
{ ...policy, name: 'custom' },
{ ...policy, name: 'read-only' },
]) {
assert.strictEqual(isCanonicalReadOnlyPermissionProfile(profile), true);
}
});

test('distinguishes extra read authority from the canonical Explore policy', () => {
const profile = createReadOnlyPermissionProfile();
const extraRead: PermissionProfileManaged = {
...profile,
fileSystem: {
...profile.fileSystem,
entries: [
...profile.fileSystem.entries,
{ kind: 'path', access: 'read', path: '/outside' },
],
},
};
assert.strictEqual(isReadOnlyPermissionProfile(extraRead), true);
assert.strictEqual(isCanonicalReadOnlyPermissionProfile(extraRead), false);
});

test('does not certify other managed policies as canonical Explore', () => {
const profile = createReadOnlyPermissionProfile();
const nonCanonical: PermissionProfileManaged[] = [
createWorkspaceWritePermissionProfile(),
createDangerFullAccessPermissionProfile(),
{ ...profile, network: { kind: 'enabled' } },
{ ...profile, fileSystem: { kind: 'restricted', entries: [] } },
{
...profile,
fileSystem: {
...profile.fileSystem,
entries: [{ kind: 'special', access: 'read', special: ':root' }],
},
},
{
...profile,
fileSystem: {
...profile.fileSystem,
protectedMetadata: { access: 'deny_write', names: ['.git'] },
},
},
];
for (const candidate of nonCanonical) {
assert.strictEqual(isCanonicalReadOnlyPermissionProfile(candidate), false);
}
});
});

describe('PermissionProfile matcher rules', () => {
test('deny entries take precedence over read and write entries', () => {
const profile: PermissionProfile = {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,28 @@
* under the License.
*/

import type { PermissionMode } from './permission.js';

export const COLLABORATION_MODES = ['agent', 'plan'] as const;

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

export function isCollaborationMode(value: unknown): value is CollaborationMode {
return typeof value === 'string' && (COLLABORATION_MODES as readonly string[]).includes(value);
}

/**
* The permission mode a session runs under once its collaboration mode is
* applied: Plan mode holds the session to read-only unless it is on Bypass.
*
* Lives here because both the model composer and tool dispatch have to reach
* the same answer; a second copy of the rule is a second authority.
*/
export function resolveCollaborationPermissionMode(input: {
readonly collaborationMode: CollaborationMode;
readonly permissionMode: PermissionMode;
}): PermissionMode {
return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass'
? 'explore'
: input.permissionMode;
}
20 changes: 20 additions & 0 deletions packages/core/src/permission-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,26 @@ export function isReadOnlyPermissionProfile(profile: PermissionProfileManaged):
);
}

/**
* True only for the canonical Explore policy, independently of its display name.
* A read-only profile may still grant extra read paths that restoring Explore
* would revoke. Treat every other policy conservatively as a possible narrowing.
* Storage and Runtime must agree on which policy an Explore reset preserves.
*/
export function isCanonicalReadOnlyPermissionProfile(profile: PermissionProfileManaged): boolean {
const { fileSystem, network } = profile;
const entry = fileSystem.entries[0];
return (
fileSystem.kind === 'restricted' &&
fileSystem.protectedMetadata === undefined &&
fileSystem.entries.length === 1 &&
entry?.kind === 'special' &&
entry.access === 'read' &&
entry.special === ':workspace_roots' &&
network.kind === 'restricted'
);
}

export function createWorkspaceWritePermissionProfile(): PermissionProfileManaged {
return {
type: 'managed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ test('cancels managed approval owners and joiners with the canonical provider id
modelId: 'model-1',
readExecutionBoundary: async () =>
createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0),
readPermissionMode: async () => 'ask',
newId: nextId(),
now: nextNow(),
getPermissionPauseTarget: () => null,
Expand Down
Loading