# Secure Python Dependencies Before Your AI Agent Installs One

Source: https://normanholz.de/en/articles/prevent-python-supply-chain-attacks/
Language: en
Last updated: 2026-05-27
Reading time: 8 min read

A malicious dependency can read SSH keys, API tokens, and CI secrets before your code ever runs. Safer uv, npm, and JavaScript package manager defaults reduce the blast radius.

Supply chain security is not the most exciting engineering topic.

That is probably why teams keep skipping it.

In Python, skipping it is dangerous. Every time you install and import a package, you give someone else's code a chance to run on your machine, in your CI environment, or inside your production build.

That is fine when the package is legitimate.

It is ugly when the package is compromised.

The recent attacks across npm and PyPI follow a simple pattern. A maintainer account gets phished. A CI token leaks. A package with a lookalike name gets published. A short-lived malicious version lands in the registry. Then a developer, build server, or AI coding agent installs it.

From there, the attacker does not need magic.

They look for environment variables, SSH keys, API tokens, cloud credentials, package publishing tokens, and anything else the process can read. In the worst cases, the attack spreads again through stolen tokens.

This is not a reason to stop using dependencies.

It is a reason to stop treating dependencies as free.

Especially now that AI agents can add packages while you are barely watching.

Here are the defaults I would put into every Python project today.

If you work in JavaScript or TypeScript, stay with me. There is a separate npm section at the end.

## 1. Stop using pip directly for project installs

`pip` is not bad.

But `pip install something` is too easy to run without leaving enough project policy behind.

For application projects, I would use `uv`.

Not because it is fashionable. Because it gives you a project file, a lockfile, fast installs, and settings that can make unsafe dependency drift much harder.

Start a project like this:

```bash
uv init
uv add pydantic
```

That gives you a `pyproject.toml` and a `uv.lock`.

The `pyproject.toml` describes what your project wants.

The `uv.lock` records what was actually resolved.

Both matter.

A `requirements.txt` file can still be useful in some environments. But if your main project setup is still a long list of packages and a habit of running `pip install -r requirements.txt`, you are missing an easy place to encode safer behavior.

You want dependency policy in version control.

Not in someone's memory.

## 2. Pin new direct dependencies exactly

By default, many tools add dependencies with lower bounds.

You ask for `pydantic`, and the project records something like:

```toml
dependencies = [
  "pydantic>=2.12.0",
]
```

That means a future install can accept a newer version.

Most of the time, that is convenient.

During a supply chain incident, convenience is not your friend.

For applications, I prefer exact bounds for direct dependencies. In `uv`, add this to your `pyproject.toml`:

```toml
[tool.uv]
add-bounds = "exact"
```

Now when you add a package, `uv` records the direct dependency with an exact version:

```toml
dependencies = [
  "pydantic==2.12.0",
]
```

This does not remove the need for a lockfile. Transitive dependencies still need to be resolved and locked.

But it changes the default from "take anything newer" to "take the version I chose."

That is a better default for production applications.

Upgrades should be a deliberate act. Someone should run the upgrade, review the diff, check the package, and commit the updated lockfile.

If your AI agent can quietly turn one package into a newer package without asking, you do not have automation.

You have unattended trust.

## 3. Add a release cooldown

Many malicious package versions do not stay online for long.

That does not help you if your CI installs the package during the first hour.

This is why I like a release cooldown.

In `uv`, you can tell the resolver to ignore package versions newer than a certain age:

```toml
[tool.uv]
add-bounds = "exact"
exclude-newer = "7 days"
```

The exact window is a team decision.

Seven days is a reasonable starting point for many application teams. It gives the ecosystem time to catch obvious malicious releases before your project is allowed to resolve them.

It is not perfect.

An attacker can sit quietly for longer than a week. A legitimate urgent patch may also be younger than seven days. You still need judgment.

But most teams are not missing urgent package releases every morning.

They are exposed because any fresh release can enter their project by default.

Change that default.

And do not just paste the setting because someone on the internet said so. Test it in a throwaway project. Add a package. Change the cooldown to a large number. Add the package again. Watch which version resolves.

You should understand the guardrail before you trust it.

## 4. Use locked sync in CI

The lockfile is only useful if your install process respects it.

This is the command I want in CI:

```bash
uv sync --locked
```

The `--locked` flag tells `uv` not to update the lockfile as part of the sync.

If `pyproject.toml` and `uv.lock` disagree, the command fails.

That is exactly what you want.

Imagine an AI agent adds a package to `pyproject.toml`, but the lockfile does not change. Or someone manually edits a dependency without running a proper resolve. A normal sync can paper over that mistake by updating the environment.

A locked sync turns it into a visible error.

That error is not annoying.

It is the system doing its job.

In local development, I also like using locked sync when I am not actively changing dependencies. It makes accidental dependency changes show up early, before they become a CI surprise.

## 5. Tell your AI agents not to add packages without asking

This is the part many teams miss.

The package manager is only one layer. Your AI coding agent is another.

Agents are very good at reaching for dependencies. Ask for a small feature, and they may install a new parser, formatter, HTTP helper, CLI wrapper, test plugin, or random package that happened to appear in a generated plan.

That habit was already messy.

Now it is a security problem.

Add a dependency rule to your `AGENTS.md`, `CLAUDE.md`, or whatever instruction file your team uses:

```md
## Dependencies

Do not add, remove, or upgrade Python dependencies without asking first.

Before proposing a new dependency:
- Check whether the standard library or an existing dependency can solve it.
- Verify the package name on PyPI.
- Prefer maintained packages with clear ownership and recent activity.
- Use `uv add`, never `pip install`, for project dependencies.
- Include the `pyproject.toml` and `uv.lock` changes in the same diff.
```

This one rule blocks a lot of nonsense.

It also pushes the team into a better question:

Does this dependency earn its place?

Sometimes the answer is yes. Rebuilding cryptography, HTTP clients, database drivers, or serious parsing logic is a bad idea.

Sometimes the answer is no. If you only need a 30-line helper from a 4,000-line package, own the 30 lines.

Dependencies are not free. They bring code, maintainers, release processes, transitive dependencies, licenses, vulnerabilities, and supply chain risk.

Treat that like a technical decision, not a reflex.

## If you use npm, pnpm, Yarn, or Bun

`uv` is Python-only.

The JavaScript and TypeScript ecosystem needs the same discipline, just with different commands.

If you use npm, commit your `package-lock.json` and use this in CI:

```bash
npm ci
```

Do not use `npm install` in CI unless you explicitly want npm to update the dependency tree.

`npm ci` requires an existing lockfile. If `package.json` and `package-lock.json` disagree, it fails instead of quietly updating the lockfile. That is the behavior you want.

Then add a project-level `.npmrc`:

```ini
save-exact=true
min-release-age=7
```

Or create it with npm itself:

```bash
npm config set save-exact true --location=project
npm config set min-release-age 7 --location=project
```

`save-exact=true` makes new direct dependencies land as exact versions instead of caret ranges.

`min-release-age=7` tells current npm to avoid versions that were published less than seven days ago when it resolves new packages.

If your npm version does not support `min-release-age`, upgrade npm or enforce the delay somewhere else, for example through a private registry, dependency proxy, or update bot policy. A lockfile protects repeat installs. It does not create a cooldown for new resolutions.

The common alternatives have similar controls:

```yaml
# pnpm-workspace.yaml
minimumReleaseAge: 10080
minimumReleaseAgeStrict: true
```

```bash
pnpm install --frozen-lockfile
```

pnpm uses minutes, so `10080` is seven days.

For Yarn 4:

```yaml
# .yarnrc.yml
defaultSemverRangePrefix: ""
npmMinimalAgeGate: "7d"
```

```bash
yarn install --immutable
```

For Bun:

```toml
# bunfig.toml
[install]
minimumReleaseAge = 604800
frozenLockfile = true
```

Bun uses seconds, so `604800` is seven days.

The names differ, but the policy is the same: exact direct dependencies, committed lockfiles, frozen CI installs, and a delay before fresh package versions can enter your project.

## The simple rule

You cannot make supply chain attacks impossible.

But you can remove the easy path.

Use `uv` for Python. Use `npm ci` and project-level npm settings for JavaScript, or the equivalent controls in pnpm, Yarn, or Bun. Pin direct dependencies exactly. Add a release cooldown. Run locked installs in CI. Tell your AI agents that new packages require permission.

That will not protect you from every attack.

It will protect you from a lot of careless ones.

And that matters, because most security incidents do not start with a genius attacker and a movie plot.

They start with a normal developer workflow that trusted one package too many.

If your team is adding AI agents to a Python codebase and wants sane engineering defaults before the next dependency lands, [that is exactly the kind of setup a fractional CTO can help with](/en/).
