Ctrl K
ComunidadGuías…

electron/main/localFileSecurity.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import fsp from 'node:fs/promises';
import path from 'node:path';

const EXECUTABLE_FILE_EXTENSIONS = new Set([
  '.app',
  '.appimage',
  '.appref-ms',
  '.action',
  '.applescript',
  '.bash',
  '.bat',
  '.bin',
  '.cmd',
  '.com',
  '.command',
  '.cpl',
  '.csh',
  '.desktop',
  '.dmg',
  '.exe',
  '.fish',
  '.gadget',
  '.hta',
  '.jar',
  '.js',
  '.jse',
  '.ksh',
  '.lnk',
  '.msh',
  '.msh1',
  '.msh1xml',
  '.msh2',
  '.msh2xml',
  '.mshxml',
  '.msi',
  '.msp',
  '.php',
  '.pif',
  '.pl',
  '.pkg',
  '.ps1',
  '.psd1',
  '.psm1',
  '.py',
  '.pyw',
  '.reg',
  '.rb',
  '.run',
  '.scpt',
  '.scptd',
  '.scr',
  '.sct',
  '.sh',
  '.url',
  '.vb',
  '.vbs',
  '.vbe',
  '.workflow',
  '.ws',
  '.wsc',
  '.wsf',
  '.wsh',
  '.zsh',
]);

export type LocalFileAuthorization =
  | { allowed: true; filePath: string }
  | { allowed: false; reason: 'invalid' | 'missing' | 'outside-roots' };

export function isMainRendererSender(
  senderId: number,
  mainRendererId: number | null | undefined
): boolean {
  return typeof mainRendererId === 'number' && senderId === mainRendererId;
}

function isPathInsideRoot(candidate: string, root: string): boolean {
  const relative = path.relative(root, candidate);
  return (
    relative === '' ||
    (!relative.startsWith('..') && !path.isAbsolute(relative))
  );
}

/**
 * Authorize an existing path against real filesystem roots.
 *
 * Both the candidate and roots are resolved through realpath so a symlink
 * stored inside a workspace cannot escape to ~/.ssh, ~/.aws, or another
 * location outside the active Space.
 */
export async function authorizeLocalFilePath(
  candidatePath: string,
  allowedRoots: Iterable<string>
): Promise<LocalFileAuthorization> {
  if (!candidatePath || !path.isAbsolute(candidatePath)) {
    return { allowed: false, reason: 'invalid' };
  }

  let realCandidate: string;
  try {
    realCandidate = await fsp.realpath(candidatePath);
  } catch {
    return { allowed: false, reason: 'missing' };
  }

  for (const root of allowedRoots) {
    if (!root || !path.isAbsolute(root)) continue;
    try {
      const realRoot = await fsp.realpath(root);
      if (isPathInsideRoot(realCandidate, realRoot)) {
        return { allowed: true, filePath: realCandidate };
      }
    } catch {
      // A stale or missing workspace binding grants no access.
    }
  }

  return { allowed: false, reason: 'outside-roots' };
}

/**
 * Authorize a preview path from the renderer.
 *
 * Agent/file events may carry a workspace-relative path. Resolve those paths
 * only against roots that the main renderer has explicitly registered for the
 * active Space; never against process.cwd(), HOME, or application asset roots.
 * Absolute paths retain the stricter realpath containment check above.
 */
export async function authorizeLocalPreviewPath(
  candidatePath: string,
  activeWorkspaceRoots: Iterable<string>,
  additionalAbsoluteRoots: Iterable<string> = []
): Promise<LocalFileAuthorization> {
  if (!candidatePath) return { allowed: false, reason: 'invalid' };

  if (path.isAbsolute(candidatePath)) {
    return authorizeLocalFilePath(candidatePath, [
      ...activeWorkspaceRoots,
      ...additionalAbsoluteRoots,
    ]);
  }

  const roots = [...activeWorkspaceRoots].filter(
    (root) => root && path.isAbsolute(root)
  );
  if (roots.length === 0) return { allowed: false, reason: 'invalid' };

  let sawMissing = false;
  for (const root of roots) {
    const authorization = await authorizeLocalFilePath(
      path.resolve(root, candidatePath),
      [root]
    );
    if (authorization.allowed) return authorization;
    if (authorization.reason === 'missing') sawMissing = true;
  }

  return {
    allowed: false,
    reason: sawMissing ? 'missing' : 'outside-roots',
  };
}

function hasExecutableExtension(candidatePath: string): boolean {
  return EXECUTABLE_FILE_EXTENSIONS.has(
    path.extname(candidatePath).toLowerCase()
  );
}

/**
 * Detect a directory the OS launches instead of browsing, such as a macOS
 * `.app` bundle.
 *
 * Mode bits cannot make this call: every traversable directory carries the
 * execute bit, so {@link isExecutableExternalOpenPath} would match all of
 * them. Only the extension distinguishes a bundle from an ordinary folder.
 */
export function isExecutableBundlePath(directoryPath: string): boolean {
  return hasExecutableExtension(directoryPath);
}

export function isExecutableExternalOpenPath(
  filePath: string,
  mode = 0
): boolean {
  return hasExecutableExtension(filePath) || (mode & 0o111) !== 0;
}