| import { |
| canReadPath, |
| canWritePath, |
| type FileSystemPathMatch, |
| type PermissionProfile, |
| type PermissionProfileManaged, |
| type PermissionProfileMatchContext, |
| } from './permission-profile.js'; |
| |
| export const ADDITIONAL_PERMISSION_ACCESS_MODES = ['read', 'write'] as const; |
| export type AdditionalPermissionAccess = (typeof ADDITIONAL_PERMISSION_ACCESS_MODES)[number]; |
| |
| export const ADDITIONAL_PERMISSION_SCOPES = ['exact', 'subtree'] as const; |
| export type AdditionalPermissionScope = (typeof ADDITIONAL_PERMISSION_SCOPES)[number]; |
| |
| export const MAX_ADDITIONAL_FILESYSTEM_ENTRIES = 32; |
| export const MAX_ADDITIONAL_PERMISSION_PATH_CHARS = 4096; |
| export const MAX_ADDITIONAL_PERMISSION_SERIALIZED_BYTES = 64 * 1024; |
| |
| export interface AdditionalFileSystemPermission { |
| readonly path: string; |
| readonly access: AdditionalPermissionAccess; |
| readonly scope: AdditionalPermissionScope; |
| } |
| |
| export interface AdditionalPermissionProfile { |
| readonly fileSystem?: { |
| readonly entries: readonly AdditionalFileSystemPermission[]; |
| }; |
| readonly network?: { |
| readonly enabled: true; |
| }; |
| } |
| |
| export interface AdditionalPermissionRiskSummary { |
| readonly outsideWorkspace: boolean; |
| readonly protectedMetadata: boolean; |
| readonly networkEnabled: boolean; |
| } |
| |
| export type AdditionalPermissionValidationFailureReason = |
| | 'invalid_profile' |
| | 'empty_profile' |
| | 'too_many_entries' |
| | 'invalid_entry' |
| | 'invalid_path' |
| | 'path_too_long' |
| | 'payload_too_large'; |
| |
| export type AdditionalPermissionValidationResult = |
| | { ok: true; profile: AdditionalPermissionProfile } |
| | { ok: false; reason: AdditionalPermissionValidationFailureReason; message: string }; |
| |
| export function validateAdditionalPermissionProfile( |
| input: unknown, |
| ): AdditionalPermissionValidationResult { |
| if (!isRecord(input)) |
| return invalid('invalid_profile', 'Additional permission profile must be an object.'); |
| if (hasUnexpectedKeys(input, ['fileSystem', 'network'])) { |
| return invalid('invalid_profile', 'Additional permission profile contains unsupported fields.'); |
| } |
| |
| const entriesResult = validateFilesystem(input.fileSystem); |
| if (!entriesResult.ok) return entriesResult; |
| const networkResult = validateNetwork(input.network); |
| if (!networkResult.ok) return networkResult; |
| if (entriesResult.entries.length === 0 && !networkResult.enabled) { |
| return invalid( |
| 'empty_profile', |
| 'Additional permission profile must contain at least one permission.', |
| ); |
| } |
| |
| const profile: AdditionalPermissionProfile = { |
| ...(entriesResult.entries.length > 0 |
| ? { fileSystem: { entries: compactAdditionalFileSystemPermissions(entriesResult.entries) } } |
| : {}), |
| ...(networkResult.enabled ? { network: { enabled: true as const } } : {}), |
| }; |
| if (serializedByteLength(profile) > MAX_ADDITIONAL_PERMISSION_SERIALIZED_BYTES) { |
| return invalid( |
| 'payload_too_large', |
| 'Additional permission profile exceeds the serialized size limit.', |
| ); |
| } |
| return { ok: true, profile }; |
| } |
| |
| export function compactAdditionalFileSystemPermissions( |
| entries: readonly AdditionalFileSystemPermission[], |
| ): readonly AdditionalFileSystemPermission[] { |
| const sorted = [...entries] |
| .map((entry) => ({ ...entry, path: trimTrailingSlashes(entry.path) })) |
| .sort(compareEntries); |
| const compacted: AdditionalFileSystemPermission[] = []; |
| for (const entry of sorted) { |
| if (compacted.some((existing) => permissionCovers(existing, entry))) continue; |
| for (let index = compacted.length - 1; index >= 0; index -= 1) { |
| if (permissionCovers(entry, compacted[index]!)) compacted.splice(index, 1); |
| } |
| compacted.push(entry); |
| } |
| return compacted.sort(compareEntries); |
| } |
| |
| export function additionalPermissionMatchesPath( |
| entry: AdditionalFileSystemPermission, |
| path: string, |
| access: AdditionalPermissionAccess, |
| ): boolean { |
| if (access === 'write' && entry.access !== 'write') return false; |
| if (entry.scope === 'exact') return samePath(path, entry.path); |
| return pathWithinRoot(path, entry.path); |
| } |
| |
| export function additionalPermissionAllowsPath( |
| profile: AdditionalPermissionProfile, |
| path: string, |
| access: AdditionalPermissionAccess, |
| ): boolean { |
| return ( |
| profile.fileSystem?.entries.some((entry) => |
| additionalPermissionMatchesPath(entry, path, access), |
| ) ?? false |
| ); |
| } |
| |
| export function applyAdditionalPermissionProfile( |
| base: PermissionProfile, |
| additional: AdditionalPermissionProfile, |
| ): PermissionProfile { |
| if (base.type !== 'managed' || base.fileSystem.kind === 'unrestricted') return base; |
| |
| const entries = additional.fileSystem?.entries ?? []; |
| const fileSystemEntries = [ |
| ...base.fileSystem.entries, |
| ...entries.map((entry) => ({ |
| kind: 'path' as const, |
| access: entry.access, |
| path: entry.path, |
| match: entry.scope satisfies FileSystemPathMatch, |
| })), |
| ]; |
| |
| return { |
| ...base, |
| fileSystem: { |
| ...base.fileSystem, |
| entries: fileSystemEntries, |
| }, |
| network: additional.network?.enabled ? { kind: 'enabled' } : base.network, |
| } satisfies PermissionProfileManaged; |
| } |
| |
| export function additionalPermissionRequiredForPath(input: { |
| profile: PermissionProfile; |
| path: string; |
| access: AdditionalPermissionAccess; |
| context?: PermissionProfileMatchContext; |
| }): boolean { |
| return input.access === 'write' |
| ? !canWritePath(input.profile, input.path, input.context) |
| : !canReadPath(input.profile, input.path, input.context); |
| } |
| |
| export function serializeAdditionalPermissionProfile(profile: AdditionalPermissionProfile): string { |
| const validated = validateAdditionalPermissionProfile(profile); |
| if (!validated.ok) throw new Error(validated.message); |
| return JSON.stringify(validated.profile); |
| } |
| |
| function validateFilesystem( |
| input: unknown, |
| ): |
| | { ok: true; entries: AdditionalFileSystemPermission[] } |
| | Extract<AdditionalPermissionValidationResult, { ok: false }> { |
| if (input === undefined) return { ok: true, entries: [] }; |
| if (!isRecord(input) || hasUnexpectedKeys(input, ['entries']) || !Array.isArray(input.entries)) { |
| return invalid('invalid_profile', 'fileSystem must contain an entries array.'); |
| } |
| if (input.entries.length > MAX_ADDITIONAL_FILESYSTEM_ENTRIES) { |
| return invalid( |
| 'too_many_entries', |
| `Additional filesystem permissions are limited to ${MAX_ADDITIONAL_FILESYSTEM_ENTRIES} entries.`, |
| ); |
| } |
| const entries: AdditionalFileSystemPermission[] = []; |
| for (const candidate of input.entries) { |
| if (!isRecord(candidate) || hasUnexpectedKeys(candidate, ['path', 'access', 'scope'])) { |
| return invalid( |
| 'invalid_entry', |
| 'Additional filesystem permission contains unsupported fields.', |
| ); |
| } |
| if ( |
| typeof candidate.path !== 'string' || |
| !ADDITIONAL_PERMISSION_ACCESS_MODES.includes( |
| candidate.access as AdditionalPermissionAccess, |
| ) || |
| !ADDITIONAL_PERMISSION_SCOPES.includes(candidate.scope as AdditionalPermissionScope) |
| ) { |
| return invalid( |
| 'invalid_entry', |
| 'Additional filesystem permission must contain path, access, and scope.', |
| ); |
| } |
| if (!isNormalizedAbsolutePath(candidate.path)) { |
| return invalid( |
| 'invalid_path', |
| 'Additional filesystem permission path must be a normalized absolute POSIX path.', |
| ); |
| } |
| if (candidate.path.length > MAX_ADDITIONAL_PERMISSION_PATH_CHARS) { |
| return invalid( |
| 'path_too_long', |
| 'Additional filesystem permission path exceeds the length limit.', |
| ); |
| } |
| entries.push({ |
| path: trimTrailingSlashes(candidate.path), |
| access: candidate.access as AdditionalPermissionAccess, |
| scope: candidate.scope as AdditionalPermissionScope, |
| }); |
| } |
| return { ok: true, entries }; |
| } |
| |
| function validateNetwork( |
| input: unknown, |
| ): { ok: true; enabled: boolean } | Extract<AdditionalPermissionValidationResult, { ok: false }> { |
| if (input === undefined) return { ok: true, enabled: false }; |
| if (!isRecord(input) || hasUnexpectedKeys(input, ['enabled']) || input.enabled !== true) { |
| return invalid('invalid_profile', 'network additional permission only supports enabled: true.'); |
| } |
| return { ok: true, enabled: true }; |
| } |
| |
| function permissionCovers( |
| existing: AdditionalFileSystemPermission, |
| candidate: AdditionalFileSystemPermission, |
| ): boolean { |
| if (candidate.access === 'write' && existing.access !== 'write') return false; |
| if (existing.scope === 'exact') { |
| return candidate.scope === 'exact' && samePath(existing.path, candidate.path); |
| } |
| return pathWithinRoot(candidate.path, existing.path); |
| } |
| |
| function compareEntries( |
| a: AdditionalFileSystemPermission, |
| b: AdditionalFileSystemPermission, |
| ): number { |
| return ( |
| a.path.localeCompare(b.path) || |
| (a.scope === b.scope ? 0 : a.scope === 'subtree' ? -1 : 1) || |
| (a.access === b.access ? 0 : a.access === 'write' ? -1 : 1) |
| ); |
| } |
| |
| function isNormalizedAbsolutePath(path: string): boolean { |
| if (!path.startsWith('/') || path.includes('\0') || path.includes('\\')) return false; |
| if (path.length > 1 && path.endsWith('/')) return false; |
| return !path |
| .split('/') |
| .some((segment, index) => index > 0 && (segment === '' || segment === '.' || segment === '..')); |
| } |
| |
| function pathWithinRoot(path: string, root: string): boolean { |
| const normalizedPath = trimTrailingSlashes(path); |
| const normalizedRoot = trimTrailingSlashes(root); |
| if (normalizedRoot === '/') return normalizedPath.startsWith('/'); |
| return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); |
| } |
| |
| function samePath(a: string, b: string): boolean { |
| return trimTrailingSlashes(a) === trimTrailingSlashes(b); |
| } |
| |
| function trimTrailingSlashes(value: string): string { |
| if (value === '/') return value; |
| return value.replace(/\/+$/g, ''); |
| } |
| |
| function serializedByteLength(value: unknown): number { |
| const json = JSON.stringify(value); |
| if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(json).byteLength; |
| return json.length; |
| } |
| |
| function isRecord(value: unknown): value is Record<string, unknown> { |
| return typeof value === 'object' && value !== null && !Array.isArray(value); |
| } |
| |
| function hasUnexpectedKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean { |
| return Object.keys(value).some((key) => !allowed.includes(key)); |
| } |
| |
| function invalid( |
| reason: AdditionalPermissionValidationFailureReason, |
| message: string, |
| ): Extract<AdditionalPermissionValidationResult, { ok: false }> { |
| return { ok: false, reason, message }; |
| } |