From cab3a0aba32036b90027e1ead65f6b77a4e6d1b2 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 25 Nov 2025 14:10:20 -0800 Subject: [PATCH] Add uv (Astral Python package manager) support - Add uv package manager implementation following pip pattern - Configure MITM proxy with CA bundle for PyPI packages - Add shell integration (bash/zsh/fish/PowerShell) - Conditional on --include-python flag - Add 33 comprehensive E2E tests covering: - uv pip install/sync/compile commands - uv add for project dependencies - uv tool install for global tools - uv run --with for ephemeral dependencies - uv sync for project syncing - Malware blocking verification for all methods - Update documentation and package.json - Install uv in Docker test environment --- README.md | 15 +- packages/safe-chain/bin/aikido-uv.js | 15 + packages/safe-chain/package.json | 3 +- .../packagemanager/currentPackageManager.js | 7 +- .../uv/createUvPackageManager.js | 19 + .../uv/createUvPackageManager.spec.js | 30 + .../src/packagemanager/uv/runUvCommand.js | 76 +++ .../src/packagemanager/uv/uvSettings.js | 5 + .../src/shell-integration/helpers.js | 1 + .../include-python/init-fish.fish | 4 + .../include-python/init-posix.sh | 4 + .../include-python/init-pwsh.ps1 | 4 + test/e2e/Dockerfile | 4 + test/e2e/uv.e2e.spec.js | 561 ++++++++++++++++++ 14 files changed, 739 insertions(+), 9 deletions(-) create mode 100755 packages/safe-chain/bin/aikido-uv.js create mode 100644 packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js create mode 100644 packages/safe-chain/src/packagemanager/uv/runUvCommand.js create mode 100644 packages/safe-chain/src/packagemanager/uv/uvSettings.js create mode 100644 test/e2e/uv.e2e.spec.js diff --git a/README.md b/README.md index c2ac0ad..7969fba 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Aikido Safe Chain -The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Javascript ecosystem (through npm, npx, yarn, pnpm, pnpx, bun and bunx). It's **free** to use and does not require any token. +The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Javascript and Python ecosystems (through npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, and pip). It's **free** to use and does not require any token. -The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip/pip3 from downloading or running the malware. +The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, pip/pip3 or uv from downloading or running the malware. Aikido Safe Chain works on Node.js version 16 and above and supports the following package managers: @@ -15,6 +15,7 @@ Aikido Safe Chain works on Node.js version 16 and above and supports the followi - ✅ **bunx** - ✅ **pip** (beta) - ✅ **pip3** (beta) +- ✅ **uv** (beta) # Usage @@ -32,7 +33,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: safe-chain setup ``` - To enable Python (pip/pip3) support (beta), use the `--include-python` flag: + To enable Python (pip/pip3/uv) support (beta), use the `--include-python` flag: ```shell safe-chain setup --include-python @@ -58,7 +59,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -70,17 +71,17 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age (npm only) For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can bypass this protection for specific installs using the `--safe-chain-skip-minimum-package-age` flag. -⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to PyPI/pip. +⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3). ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** diff --git a/packages/safe-chain/bin/aikido-uv.js b/packages/safe-chain/bin/aikido-uv.js new file mode 100755 index 0000000..b8cf210 --- /dev/null +++ b/packages/safe-chain/bin/aikido-uv.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { UV_PACKAGE_MANAGER } from "../src/packagemanager/uv/uvSettings.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +initializePackageManager(UV_PACKAGE_MANAGER); + +// Pass through only user-supplied uv args +var exitCode = await main(process.argv.slice(2)); +process.exit(exitCode); diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 8bdad1f..15279d6 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -15,6 +15,7 @@ "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", + "aikido-uv": "bin/aikido-uv.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", "aikido-python": "bin/aikido-python.js", @@ -33,7 +34,7 @@ "keywords": [], "author": "Aikido Security", "license": "AGPL-3.0-or-later", - "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.", + "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", "dependencies": { "certifi": "14.5.15", "chalk": "5.4.1", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index 2db4167..f18105f 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -10,6 +10,9 @@ import { } from "./pnpm/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js"; import { createPipPackageManager } from "./pip/createPackageManager.js"; +import { createUvPackageManager } from "./uv/createUvPackageManager.js"; +import { PIP_PACKAGE_MANAGER } from "./pip/pipSettings.js"; +import { UV_PACKAGE_MANAGER } from "./uv/uvSettings.js"; /** * @type {{packageManagerName: PackageManager | null}} @@ -52,8 +55,10 @@ export function initializePackageManager(packageManagerName) { state.packageManagerName = createBunPackageManager(); } else if (packageManagerName === "bunx") { state.packageManagerName = createBunxPackageManager(); - } else if (packageManagerName === "pip") { + } else if (packageManagerName === PIP_PACKAGE_MANAGER) { state.packageManagerName = createPipPackageManager(); + } else if (packageManagerName === UV_PACKAGE_MANAGER) { + state.packageManagerName = createUvPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js new file mode 100644 index 0000000..ecd3367 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js @@ -0,0 +1,19 @@ +import { runUv } from "./runUvCommand.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createUvPackageManager() { + return { + /** + * @param {string[]} args + */ + runCommand: (args) => { + // uv is always invoked as 'uv' - no invocation variations like pip + return runUv("uv", args); + }, + // For uv, rely solely on MITM proxy to detect/deny downloads from PyPI. + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} diff --git a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js new file mode 100644 index 0000000..ba79722 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js @@ -0,0 +1,30 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createUvPackageManager } from "./createUvPackageManager.js"; + +test("createUvPackageManager", async (t) => { + await t.test("should create package manager with required interface", () => { + const pm = createUvPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); + + await t.test("should use proxy-only approach (MITM)", () => { + const pm = createUvPackageManager(); + + // uv uses proxy-only approach, so it doesn't scan args + assert.strictEqual(pm.isSupportedCommand(["pip", "install", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["add", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand([]), false); + }); + + await t.test("should return empty dependency updates", () => { + const pm = createUvPackageManager(); + + const result = pm.getDependencyUpdatesForCommand(["pip", "install", "requests"]); + assert.deepStrictEqual(result, []); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js new file mode 100644 index 0000000..85302eb --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js @@ -0,0 +1,76 @@ +import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; + +/** + * Sets CA bundle environment variables used by Python libraries and uv. + * These are applied to ensure all Python network libraries respect the combined CA bundle. + * + * @param {NodeJS.ProcessEnv} env - Environment object to modify + * @param {string} combinedCaPath - Path to the combined CA bundle + */ +function setUvCaBundleEnvironmentVariables(env, combinedCaPath) { + // UV_NATIVE_TLS: Use system-provided TLS certificates (default is true) + // But we also need to provide our CA bundle for MITM'd connections + + // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients + if (env.SSL_CERT_FILE) { + ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + } + env.SSL_CERT_FILE = combinedCaPath; + + // REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally) + if (env.REQUESTS_CA_BUNDLE) { + ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + } + env.REQUESTS_CA_BUNDLE = combinedCaPath; + + // PIP_CERT: Some underlying pip operations may respect this + if (env.PIP_CERT) { + ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + } + env.PIP_CERT = combinedCaPath; +} + +/** + * Runs a uv command with safe-chain's certificate bundle and proxy configuration. + * + * uv respects standard environment variables for proxy and TLS configuration: + * - HTTP_PROXY / HTTPS_PROXY: Proxy settings + * - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification + * + * @param {string} command - The uv command to execute (typically 'uv') + * @param {string[]} args - Command line arguments to pass to uv + * @returns {Promise<{status: number}>} Exit status of the uv command + */ +export async function runUv(command, args) { + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + + // Provide uv with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots) + // so that network requests validate correctly under both MITM'd and tunneled HTTPS. + const combinedCaPath = getCombinedCaBundlePath(); + + // Set CA bundle environment variables for uv and underlying Python libraries + setUvCaBundleEnvironmentVariables(env, combinedCaPath); + + // Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration + // These are already set by mergeSafeChainProxyEnvironmentVariables + + const result = await safeSpawn(command, args, { + stdio: "inherit", + env, + }); + + return { status: result.status }; + } catch (/** @type any */ error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError(`Error executing command: ${error.message}`); + ui.writeError(`Is '${command}' installed and available on your system?`); + return { status: 1 }; + } + } +} diff --git a/packages/safe-chain/src/packagemanager/uv/uvSettings.js b/packages/safe-chain/src/packagemanager/uv/uvSettings.js new file mode 100644 index 0000000..6f68ea7 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uv/uvSettings.js @@ -0,0 +1,5 @@ +export const UV_PACKAGE_MANAGER = "uv"; + +// Unlike pip, uv only has one invocation method: the 'uv' command. +// There is no 'uv3' or 'python -m uv' pattern, so we don't need +// invocation tracking like pip does. diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index da8e98b..7f45669 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -22,6 +22,7 @@ export const knownAikidoTools = [ { tool: "pnpx", aikidoCommand: "aikido-pnpx", ecoSystem: ECOSYSTEM_JS }, { tool: "bun", aikidoCommand: "aikido-bun", ecoSystem: ECOSYSTEM_JS }, { tool: "bunx", aikidoCommand: "aikido-bunx", ecoSystem: ECOSYSTEM_JS }, + { tool: "uv", aikidoCommand: "aikido-uv", ecoSystem: ECOSYSTEM_PY }, { tool: "pip", aikidoCommand: "aikido-pip", ecoSystem: ECOSYSTEM_PY }, { tool: "pip3", aikidoCommand: "aikido-pip3", ecoSystem: ECOSYSTEM_PY }, { tool: "python", aikidoCommand: "aikido-python", ecoSystem: ECOSYSTEM_PY }, diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish index ebf89ff..235ecb8 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish @@ -77,6 +77,10 @@ function pip3 wrapSafeChainCommand "pip3" "aikido-pip3" $argv end +function uv + wrapSafeChainCommand "uv" "aikido-uv" $argv +end + # `python -m pip`, `python -m pip3`. function python wrapSafeChainCommand "python" "aikido-python" $argv diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh index 278b31a..9f51010 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh @@ -69,6 +69,10 @@ function pip3() { wrapSafeChainCommand "pip3" "aikido-pip3" "$@" } +function uv() { + wrapSafeChainCommand "uv" "aikido-uv" "$@" +} + # `python -m pip`, `python -m pip3`. function python() { wrapSafeChainCommand "python" "aikido-python" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 index b692107..e2ea1c9 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 @@ -95,6 +95,10 @@ function pip3 { Invoke-WrappedCommand "pip3" "aikido-pip3" $args } +function uv { + Invoke-WrappedCommand "uv" "aikido-uv" $args +} + # `python -m pip`, `python -m pip3`. function python { Invoke-WrappedCommand 'python' 'aikido-python' $args diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index cf5f39b..52ff3dc 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -67,6 +67,10 @@ except Exception as exc: raise EOF +# Install uv (Astral's fast Python package manager) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ + echo 'source $HOME/.local/bin/env' >> ~/.bashrc + # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ RUN npm install -g /pkgs/*.tgz diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js new file mode 100644 index 0000000..eae7c12 --- /dev/null +++ b/test/e2e/uv.e2e.spec.js @@ -0,0 +1,561 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: uv coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup --include-python"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`successfully installs known safe packages with uv pip install`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with specific version`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests==2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with version specifiers (>=)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "Jinja2>=3.1"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with extras such as requests[socks]`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "requests[socks]==2.32.3"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install multiple packages`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests certifi urllib3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install from requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create a requirements.txt file + await shell.runCommand("echo 'requests==2.32.3' > requirements.txt"); + await shell.runCommand("echo 'certifi>=2024.0.0' >> requirements.txt"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages -r requirements.txt" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip sync with requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create a requirements.txt file + await shell.runCommand("echo 'requests==2.32.3' > requirements-sync.txt"); + + const result = await shell.runCommand( + "uv pip sync --system --break-system-packages requirements-sync.txt" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious Python packages via uv`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + const listResult = await shell.runCommand("uv pip list --system"); + assert.ok( + !listResult.output.includes("safe-chain-pi-test"), + `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` + ); + }); + + it(`uv pip install from GitHub URL using the CA bundle`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages git+https://github.com/psf/requests.git@v2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` + ); + }); + + it(`uv pip successfully validates certificates for HTTPS downloads`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working) + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation should succeed with proper certificate validation. Output was:\n${result.output}` + ); + + // Should NOT contain SSL or certificate errors + assert.ok( + !result.output.match( + /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i + ), + `Should not have SSL/certificate errors. Output was:\n${result.output}` + ); + }); + + it(`uv pip install from direct HTTPS wheel URL`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from direct HTTPS URL failed. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --upgrade flag`, async () => { + const shell = await container.openShell("zsh"); + + // First install a package + await shell.runCommand("uv pip install --system --break-system-packages requests==2.31.0"); + + // Then upgrade it + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --upgrade requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --no-deps flag`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --no-deps requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --editable flag from local directory`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple package structure + await shell.runCommand("mkdir -p /tmp/test-pkg"); + await shell.runCommand("echo 'from setuptools import setup' > /tmp/test-pkg/setup.py"); + await shell.runCommand("echo \"setup(name='test-pkg', version='0.1.0')\" >> /tmp/test-pkg/setup.py"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages -e /tmp/test-pkg" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip compile creates locked requirements`, async () => { + const shell = await container.openShell("zsh"); + + // Create an input requirements file + await shell.runCommand("echo 'requests' > requirements.in"); + + const result = await shell.runCommand( + "uv pip compile requirements.in" + ); + + // uv pip compile doesn't install packages, just resolves dependencies + // It should complete successfully and output resolved requirements + assert.ok( + result.output.includes("requests==") || result.output.includes("# via"), + `Output did not include compiled requirements. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --index-url for alternate registry`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --index-url https://test.pypi.org/simple certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Should succeed if CA bundle properly handles tunneled hosts + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --safe-chain-logging=verbose`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with version range constraint`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "requests>=2.31.0,<2.33.0"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip list shows installed packages`, async () => { + const shell = await container.openShell("zsh"); + + // Install a package first + await shell.runCommand("uv pip install --system --break-system-packages requests"); + + // Then list packages - this shouldn't trigger safe-chain scanning + const result = await shell.runCommand("uv pip list --system"); + + // List command should work without malware scanning + assert.ok( + result.output.includes("requests") || result.output.length > 0, + `Output did not show package list. Output was:\n${result.output}` + ); + }); + + it(`uv add installs package and updates project`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project and add package in same command + const result = await shell.runCommand( + "uv init test-project && cd test-project && uv add requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add with specific version`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-version"); + + const result = await shell.runCommand( + "cd test-project-version && uv add requests==2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add --dev for development dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-dev"); + + const result = await shell.runCommand( + "cd test-project-dev && uv add --dev pytest" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add multiple packages at once`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-multi"); + + const result = await shell.runCommand( + "cd test-project-multi && uv add requests certifi urllib3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks malicious packages via uv add`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-malware"); + + const result = await shell.runCommand( + "cd test-project-malware && uv add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv tool install installs a global tool`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv tool install ruff" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installed"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks malicious packages via uv tool install`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv tool install safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv run --with installs ephemeral dependency`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand("echo 'import requests; print(requests.__version__)' > test_script.py"); + + const result = await shell.runCommand( + "uv run --with requests test_script.py" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks malicious packages via uv run --with`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); + + const result = await shell.runCommand( + "uv run --with safe-chain-pi-test test_script2.py" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv sync syncs project dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project, add a dependency, remove venv, and sync in one command chain + const result = await shell.runCommand( + "uv init test-sync-project && cd test-sync-project && uv add requests && rm -rf .venv && uv sync" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add from git URL`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-git-add"); + + const result = await shell.runCommand( + "cd test-git-add && uv add git+https://github.com/psf/requests.git@v2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add with --optional group`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-optional"); + + const result = await shell.runCommand( + "cd test-optional && uv add --optional dev pytest" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv run --with-requirements installs from requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create requirements file and script + await shell.runCommand("echo 'requests' > run_requirements.txt"); + await shell.runCommand("echo 'import requests; print(requests.__version__)' > run_script.py"); + + const result = await shell.runCommand( + "uv run --with-requirements run_requirements.txt run_script.py" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv sync --all-extras syncs all optional dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize project with optional dependency and sync in one command chain + const result = await shell.runCommand( + "uv init test-extras && cd test-extras && uv add --optional dev pytest && uv sync --all-extras" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); +}); +