Ctrl K
ComunidadGuías…

prep/installPythonDependencies.ts

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

Archivo original ↗Preguntar a KiBot
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { log } from "../utils/cli.ts";
import { buildSandboxedCommand } from "../utils/sandbox.ts";
import { filterEnvForUntrustedCode } from "../utils/secrets.ts";
import { spawn } from "../utils/subprocess.ts";
import type {
  PrepDefinition,
  PrepOptions,
  PythonPackageManager,
  PythonPrepResult,
} from "./types.ts";

interface PythonConfig {
  file: string;
  tool: PythonPackageManager;
  installCmd: string[];
  /** extra applicability check beyond file existence (defaults to always applies). */
  applies?: (path: string) => boolean;
}

/**
 * heuristic proxy for "this pyproject.toml is a distributable package."
 *
 * `[build-system]` is NOT a pip requirement — pip treats a pyproject without it
 * as the legacy `setuptools.build_meta:__legacy__` backend and proceeds. but
 * modern scaffolds (hatchling/flit/poetry-core/setuptools>=61) all emit
 * `[build-system]`, so its presence is a good signal. gating on it trades a rare
 * false-negative (a hand-written PEP 621 package that omits `[build-system]` is
 * skipped) to kill the common false-positive: a tooling-only pyproject (ruff/mypy,
 * PEP 735 dependency groups under uv) where `pip install .` triggers setuptools
 * flat-layout auto-discovery and hard-fails on a non-package repo like hey-api
 * (root holds web/, packages/, node_modules/).
 */
function declaresBuildSystem(path: string): boolean {
  return /^\s*\[\s*build-system\s*\]/m.test(readFileSync(path, "utf8"));
}

function configApplies(config: PythonConfig, cwd: string): boolean {
  const path = join(cwd, config.file);
  return existsSync(path) && (config.applies?.(path) ?? true);
}

// python dependency file patterns in priority order
const PYTHON_CONFIGS: PythonConfig[] = [
  {
    file: "requirements.txt",
    tool: "pip",
    installCmd: ["pip", "install", "-r", "requirements.txt"],
  },
  {
    file: "pyproject.toml",
    tool: "pip",
    installCmd: ["pip", "install", "."],
    applies: declaresBuildSystem,
  },
  {
    file: "Pipfile",
    tool: "pipenv",
    installCmd: ["pipenv", "install"],
  },
  {
    file: "Pipfile.lock",
    tool: "pipenv",
    installCmd: ["pipenv", "sync"],
  },
  {
    file: "poetry.lock",
    tool: "poetry",
    installCmd: ["poetry", "install", "--no-interaction"],
  },
  {
    file: "setup.py",
    tool: "pip",
    installCmd: ["pip", "install", "-e", "."],
  },
];

// tool install commands (via pip)
const TOOL_INSTALL_COMMANDS: Record<string, string[]> = {
  pipenv: ["pip", "install", "pipenv"],
  poetry: ["pip", "install", "poetry"],
};

async function isCommandAvailable(command: string): Promise<boolean> {
  const result = await spawn(
    buildSandboxedCommand({ cmd: "which", args: [command], env: filterEnvForUntrustedCode() })
  );
  return result.exitCode === 0;
}

async function installTool(name: string): Promise<string | null> {
  const installCmd = TOOL_INSTALL_COMMANDS[name];
  if (!installCmd) {
    // tool doesn't need installation (e.g., pip)
    return null;
  }

  log.info(`» installing ${name}...`);
  const [cmd, ...args] = installCmd;
  // sandbox tool provisioning too: a repo pip.conf can repoint the index at an
  // attacker sdist whose setup.py runs during `pip install poetry`.
  const result = await spawn({
    ...buildSandboxedCommand({ cmd, args, env: filterEnvForUntrustedCode() }),
    onStderr: (chunk) => process.stderr.write(chunk),
  });

  if (result.exitCode !== 0) {
    return result.stderr || `failed to install ${name}`;
  }

  log.info(`» installed ${name}`);
  return null;
}

export const installPythonDependencies: PrepDefinition = {
  name: "installPythonDependencies",

  shouldRun: async () => {
    // check if python is available
    const hasPython = (await isCommandAvailable("python3")) || (await isCommandAvailable("python"));
    if (!hasPython) {
      return false;
    }

    // check if any applicable python config file exists
    const cwd = process.cwd();
    return PYTHON_CONFIGS.some((config) => configApplies(config, cwd));
  },

  run: async (options: PrepOptions): Promise<PythonPrepResult> => {
    const cwd = process.cwd();

    // find the first applicable config
    const config = PYTHON_CONFIGS.find((c) => configApplies(c, cwd));
    if (!config) {
      return {
        language: "python",
        packageManager: "pip",
        configFile: "unknown",
        dependenciesInstalled: false,
        issues: ["no python config file found"],
      };
    }

    log.info(`» detected python config: ${config.file} (using ${config.tool})`);

    // SECURITY: when shell is disabled, skip ALL python dependency installation.
    // every python install path can potentially execute arbitrary code:
    //   - setup.py / pyproject.toml: directly execute build backends
    //   - requirements.txt: can contain "-e ." or local path references that
    //     trigger setup.py execution
    //   - Pipfile/poetry.lock: can contain path dependencies pointing to local
    //     directories with malicious setup.py
    //   - source distributions from PyPI also execute setup.py
    // there is no equivalent of npm's --ignore-scripts for pip.
    if (options.ignoreScripts) {
      log.info(
        `» skipping python install (shell disabled, python packages can execute arbitrary code)`
      );
      return {
        language: "python",
        packageManager: config.tool,
        configFile: config.file,
        dependenciesInstalled: false,
        issues: [
          `skipped: python dependency installation can execute arbitrary code (setup.py, build backends, local path references), which is blocked when shell is disabled`,
        ],
      };
    }

    // check if the tool is available, install if needed
    const isAvailable = await isCommandAvailable(config.tool);
    if (!isAvailable) {
      log.info(`» ${config.tool} not found, attempting to install...`);
      const installError = await installTool(config.tool);
      if (installError) {
        return {
          language: "python",
          packageManager: config.tool,
          configFile: config.file,
          dependenciesInstalled: false,
          issues: [installError],
        };
      }
    }

    // run the install command
    const [cmd, ...args] = config.installCmd;
    const fullCommand = `${cmd} ${args.join(" ")}`;
    log.info(`» running: ${fullCommand}`);
    // setup.py / build backends / local path deps execute arbitrary code, so run
    // the install in a fresh PID namespace (no action /proc to read) with filtered
    // env. python has no --ignore-scripts, so this is the only lever here.
    const result = await spawn(
      buildSandboxedCommand({ cmd, args, env: filterEnvForUntrustedCode() })
    );

    const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
    if (output) {
      log.startGroup(`${fullCommand} output`);
      log.info(output);
      log.endGroup();
    }

    if (result.exitCode !== 0) {
      return {
        language: "python",
        packageManager: config.tool,
        configFile: config.file,
        dependenciesInstalled: false,
        issues: [output || `${cmd} exited with code ${result.exitCode}`],
      };
    }

    return {
      language: "python",
      packageManager: config.tool,
      configFile: config.file,
      dependenciesInstalled: true,
      issues: [],
    };
  },
};