diff --git a/packages/safe-chain/bin/aikido-poetry.js b/packages/safe-chain/bin/aikido-poetry.js new file mode 100644 index 0000000..49265c0 --- /dev/null +++ b/packages/safe-chain/bin/aikido-poetry.js @@ -0,0 +1,12 @@ +#!/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"; + +setEcoSystem(ECOSYSTEM_PY); +const packageManagerName = "poetry"; +initializePackageManager(packageManagerName); +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 15279d6..607c524 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -20,6 +20,7 @@ "aikido-pip3": "bin/aikido-pip3.js", "aikido-python": "bin/aikido-python.js", "aikido-python3": "bin/aikido-python3.js", + "aikido-poetry": "bin/aikido-poetry.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index c6f4484..3dba075 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -11,6 +11,7 @@ import { import { createYarnPackageManager } from "./yarn/createPackageManager.js"; import { createPipPackageManager } from "./pip/createPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js"; +import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; /** * @type {{packageManagerName: PackageManager | null}} @@ -57,6 +58,8 @@ export function initializePackageManager(packageManagerName) { state.packageManagerName = createPipPackageManager(); } else if (packageManagerName === "uv") { state.packageManagerName = createUvPackageManager(); + } else if (packageManagerName === "poetry") { + state.packageManagerName = createPoetryPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js new file mode 100644 index 0000000..262d915 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js @@ -0,0 +1,78 @@ +import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createPoetryPackageManager() { + return { + runCommand: (args) => runPoetryCommand(args), + + // For poetry, we use the proxy-only approach to block package downloads, + // so we don't need to analyze commands. + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} + +/** + * Sets CA bundle environment variables used by Poetry and Python libraries. + * Poetry uses the Python requests library which respects these environment variables. + * + * @param {NodeJS.ProcessEnv} env - Environment object to modify + * @param {string} combinedCaPath - Path to the combined CA bundle + */ +function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) { + // SSL_CERT_FILE: Used by Python SSL libraries and requests + 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 Poetry uses) + 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: Poetry may use pip internally + 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 poetry command with safe-chain's certificate bundle and proxy configuration. + * + * Poetry respects standard HTTP_PROXY/HTTPS_PROXY environment variables through + * the Python requests library. + * + * @param {string[]} args - Command line arguments to pass to poetry + * @returns {Promise<{status: number}>} Exit status of the poetry command + */ +async function runPoetryCommand(args) { + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + + const combinedCaPath = getCombinedCaBundlePath(); + setPoetryCaBundleEnvironmentVariables(env, combinedCaPath); + + const result = await safeSpawn("poetry", 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 'poetry' installed and available on your system?"); + return { status: 1 }; + } + } +} diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.spec.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.spec.js new file mode 100644 index 0000000..a49cd27 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createPoetryPackageManager } from "./createPoetryPackageManager.js"; + +test("createPoetryPackageManager", async (t) => { + await t.test("should create package manager with required interface", () => { + const pm = createPoetryPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 8169086..053d5d7 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -36,10 +36,19 @@ function getSafeChainProxyEnvironmentVariables() { return {}; } + const proxyUrl = `http://127.0.0.1:${state.port}`; return { - HTTPS_PROXY: `http://localhost:${state.port}`, - GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`, + // Uppercase variants (standard) + HTTP_PROXY: proxyUrl, + HTTPS_PROXY: proxyUrl, + GLOBAL_AGENT_HTTP_PROXY: proxyUrl, NODE_EXTRA_CA_CERTS: getCaCertPath(), + // Lowercase variants (some tools like Poetry/requests prefer these) + http_proxy: proxyUrl, + https_proxy: proxyUrl, + // Clear NO_PROXY to ensure all requests go through our proxy + NO_PROXY: "", + no_proxy: "", }; } diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 7f45669..af95284 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -25,6 +25,7 @@ export const knownAikidoTools = [ { 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: "poetry", aikidoCommand: "aikido-poetry", ecoSystem: ECOSYSTEM_PY }, { tool: "python", aikidoCommand: "aikido-python", ecoSystem: ECOSYSTEM_PY }, { tool: "python3", aikidoCommand: "aikido-python3", ecoSystem: ECOSYSTEM_PY }, // When adding a new tool here, also update the documentation for the new tool in the README.md 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 235ecb8..a849b2f 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 @@ -81,6 +81,10 @@ function uv wrapSafeChainCommand "uv" "aikido-uv" $argv end +function poetry + wrapSafeChainCommand "poetry" "aikido-poetry" $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 9f51010..693075e 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 @@ -73,6 +73,10 @@ function uv() { wrapSafeChainCommand "uv" "aikido-uv" "$@" } +function poetry() { + wrapSafeChainCommand "poetry" "aikido-poetry" "$@" +} + # `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 e2ea1c9..ab22ab8 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 @@ -99,6 +99,10 @@ function uv { Invoke-WrappedCommand "uv" "aikido-uv" $args } +function poetry { + Invoke-WrappedCommand "poetry" "aikido-poetry" $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 8c3b0a5..4e6d9cb 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -71,6 +71,11 @@ EOF RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ echo 'source $HOME/.local/bin/env' >> ~/.bashrc +# Install Poetry +RUN curl -sSL https://install.python-poetry.org | python3 - && \ + echo 'export PATH="/root/.local/bin:$PATH"' >> ~/.bashrc && \ + /root/.local/bin/poetry config virtualenvs.in-project true + # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ RUN npm install -g /pkgs/*.tgz diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js new file mode 100644 index 0000000..56c3e10 --- /dev/null +++ b/test/e2e/poetry.e2e.spec.js @@ -0,0 +1,368 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: poetry 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 poetry add`, async () => { + const shell = await container.openShell("zsh"); + + // Clear poetry cache using command to bypass safe-chain wrapper + await shell.runCommand("command poetry cache clear pypi --all -n"); + + // Initialize a new poetry project + await shell.runCommand("mkdir /tmp/test-poetry-project && cd /tmp/test-poetry-project"); + await shell.runCommand("cd /tmp/test-poetry-project && poetry init --no-interaction"); + + // Add a safe package + const result = await shell.runCommand( + "cd /tmp/test-poetry-project && poetry add requests" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry add with specific version`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-version && cd /tmp/test-poetry-version"); + await shell.runCommand("cd /tmp/test-poetry-version && poetry init --no-interaction"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-version && poetry add requests==2.32.3" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious Python packages via poetry`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-malware && cd /tmp/test-poetry-malware"); + await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("Blocked by Safe-chain"), + `Expected malware to be blocked. Output was:\n${result.output}` + ); + assert.strictEqual( + result.exitCode, + 1, + `Expected exit code 1 for blocked malware, got ${result.exitCode}` + ); + }); + + it(`poetry install installs dependencies from pyproject.toml`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-install && cd /tmp/test-poetry-install"); + await shell.runCommand("cd /tmp/test-poetry-install && poetry init --no-interaction"); + await shell.runCommand("cd /tmp/test-poetry-install && poetry add requests"); + + // Now remove the virtualenv and run install + await shell.runCommand("cd /tmp/test-poetry-install && rm -rf .venv"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-install && poetry install" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry update updates dependencies`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-update && cd /tmp/test-poetry-update"); + await shell.runCommand("cd /tmp/test-poetry-update && poetry init --no-interaction"); + await shell.runCommand("cd /tmp/test-poetry-update && poetry add requests"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-update && poetry update" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Updating"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry update with specific packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-update-specific && cd /tmp/test-poetry-update-specific"); + await shell.runCommand("cd /tmp/test-poetry-update-specific && poetry init --no-interaction"); + await shell.runCommand("cd /tmp/test-poetry-update-specific && poetry add requests certifi"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-update-specific && poetry update requests" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Updating"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry sync synchronizes environment`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-sync && cd /tmp/test-poetry-sync"); + await shell.runCommand("cd /tmp/test-poetry-sync && poetry init --no-interaction"); + await shell.runCommand("cd /tmp/test-poetry-sync && poetry add requests"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-sync && poetry sync" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry add with multiple packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-multi && cd /tmp/test-poetry-multi"); + await shell.runCommand("cd /tmp/test-poetry-multi && poetry init --no-interaction"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-multi && poetry add requests certifi" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry add with extras`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-extras && cd /tmp/test-poetry-extras"); + await shell.runCommand("cd /tmp/test-poetry-extras && poetry init --no-interaction"); + + // Use quotes to prevent shell expansion of square brackets + const result = await shell.runCommand( + 'cd /tmp/test-poetry-extras && poetry add "requests[security]"' + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry add with development group`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-dev && cd /tmp/test-poetry-dev"); + await shell.runCommand("cd /tmp/test-poetry-dev && poetry init --no-interaction"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-dev && poetry add --group dev pytest" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry install with extras`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-install-extras && cd /tmp/test-poetry-install-extras"); + await shell.runCommand("cd /tmp/test-poetry-install-extras && poetry init --no-interaction"); + await shell.runCommand('cd /tmp/test-poetry-install-extras && poetry add requests'); + await shell.runCommand("cd /tmp/test-poetry-install-extras && rm -rf .venv"); + + const result = await shell.runCommand( + 'cd /tmp/test-poetry-install-extras && poetry install' + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry install with dependency groups`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-install-groups && cd /tmp/test-poetry-install-groups"); + await shell.runCommand("cd /tmp/test-poetry-install-groups && poetry init --no-interaction"); + await shell.runCommand("cd /tmp/test-poetry-install-groups && poetry add requests"); + await shell.runCommand("cd /tmp/test-poetry-install-groups && rm -rf .venv"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-install-groups && poetry install" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry lock creates/updates lock file`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-lock && cd /tmp/test-poetry-lock"); + await shell.runCommand("cd /tmp/test-poetry-lock && poetry init --no-interaction"); + await shell.runCommand("cd /tmp/test-poetry-lock && poetry add requests"); + await shell.runCommand("cd /tmp/test-poetry-lock && rm poetry.lock"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-lock && poetry lock" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry add with version constraint using @`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-constraint && cd /tmp/test-poetry-constraint"); + await shell.runCommand("cd /tmp/test-poetry-constraint && poetry init --no-interaction"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-constraint && poetry add requests@^2.32.0" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`poetry remove does not download packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-remove && cd /tmp/test-poetry-remove"); + await shell.runCommand("cd /tmp/test-poetry-remove && poetry init --no-interaction"); + await shell.runCommand("cd /tmp/test-poetry-remove && poetry add requests"); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-remove && poetry remove requests" + ); + + // Remove should succeed - it doesn't download packages + assert.strictEqual( + result.status, + 0, + `Expected exit code 0 for remove command, got ${result.status}` + ); + }); + + it(`blocks malware during poetry install`, async () => { + const shell = await container.openShell("zsh"); + + // Create a project with malware in dependencies + await shell.runCommand("mkdir /tmp/test-poetry-install-malware && cd /tmp/test-poetry-install-malware"); + await shell.runCommand("cd /tmp/test-poetry-install-malware && poetry init --no-interaction"); + + // Add safe-chain-pi-test to pyproject.toml using sed + await shell.runCommand('cd /tmp/test-poetry-install-malware && echo "safe-chain-pi-test = \"*\"" >> pyproject.toml'); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-install-malware && poetry install 2>&1" + ); + + assert.ok( + result.output.includes("Blocked by Safe-chain"), + `Expected malware to be blocked during install. Output was:\n${result.output}` + ); + assert.strictEqual( + result.status, + 1, + `Expected exit code 1 for blocked malware during install, got ${result.status}` + ); + }); + + it(`blocks malware during poetry update`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-update-malware && cd /tmp/test-poetry-update-malware"); + await shell.runCommand("cd /tmp/test-poetry-update-malware && poetry init --no-interaction"); + + // Add safe-chain-pi-test to pyproject.toml using sed + await shell.runCommand('cd /tmp/test-poetry-update-malware && echo "safe-chain-pi-test = \"*\"" >> pyproject.toml'); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-update-malware && poetry update 2>&1" + ); + + assert.ok( + result.output.includes("Blocked by Safe-chain"), + `Expected malware to be blocked during update. Output was:\n${result.output}` + ); + assert.strictEqual( + result.status, + 1, + `Expected exit code 1 for blocked malware during update, got ${result.status}` + ); + }); + + it(`blocks malware during poetry sync`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-poetry-sync-malware && cd /tmp/test-poetry-sync-malware"); + await shell.runCommand("cd /tmp/test-poetry-sync-malware && poetry init --no-interaction"); + + // Add safe-chain-pi-test to pyproject.toml using sed + await shell.runCommand('cd /tmp/test-poetry-sync-malware && echo "safe-chain-pi-test = \"*\"" >> pyproject.toml'); + + const result = await shell.runCommand( + "cd /tmp/test-poetry-sync-malware && poetry sync 2>&1" + ); + + assert.ok( + result.output.includes("Blocked by Safe-chain"), + `Expected malware to be blocked during sync. Output was:\n${result.output}` + ); + assert.strictEqual( + result.status, + 1, + `Expected exit code 1 for blocked malware during sync, got ${result.status}` + ); + }); +});