Security model#

conda-exec runs arbitrary binaries from automatically created environments. This is inherently a trust decision, and the security model is designed to be transparent about what is and is not protected.

Trust boundaries#

Warning

conda-exec trusts your configured channels completely. Running conda exec sometool downloads and executes binaries from those channels without additional verification. Only use channels you trust, and be careful when adding third-party channels via --channel or [tool.conda].channels in script metadata.

conda-exec trusts the conda solver and the configured package repositories. If a user runs conda exec ruff, they are trusting that the ruff package from their configured channels (typically conda-forge) contains what it claims. conda-exec does not add verification beyond what conda itself provides. Channel trust, package signatures, and repository integrity are all delegated to conda’s own security model.

What conda-exec does control is everything that happens after packages are installed into the cache: which binary gets executed, how it is invoked, and what environment it runs in.

Subprocess execution#

conda-exec always invokes binaries using subprocess.run with a list of arguments:

subprocess.run([str(binary), *args], env=env)

This is never a shell invocation. The binary path and all arguments are passed directly to the operating system’s exec family of calls, bypassing any shell interpretation. There is no opportunity for shell injection regardless of what characters appear in the tool name or arguments.

The only modification to the subprocess environment is PATH manipulation. In the default mode, the prefix’s bin/ directory is prepended to PATH so the tool can find its own dependencies. With --activate, conda’s full activation logic is applied, which sets additional environment variables like CONDA_PREFIX.

Cache key validation#

Cache keys control which directory on disk an environment maps to. If a cache key could be manipulated to contain path traversal sequences, an attacker could potentially read from or write to arbitrary filesystem locations.

Three layers of validation prevent this:

  1. SAFE_TOOL_RE only allows [a-zA-Z0-9_][a-zA-Z0-9_.+-]*. This rules out /, .., null bytes, and other characters that have special meaning in file paths.

  2. SAFE_KEY_RE validates the complete {tool}--{hash} format, ensuring the hash portion contains only hex digits.

  3. Even if a key somehow passes both regexes, prefix_for() resolves the constructed path and verifies it is within the envs directory using is_relative_to(). This is the final safety net. On case-insensitive filesystems, Unicode normalization edge cases, or any other situation where regex validation might be insufficient, the resolved path check catches the escape.

Keys are also length-limited to 200 characters to prevent filesystem issues and potential buffer-related problems on platforms with strict path length limits.

File size limits#

When parsing PEP 723 inline metadata from script files, conda-exec checks the file size before reading. Files larger than 10 MB are skipped entirely, and parse_script_metadata() returns None.

This prevents memory exhaustion if conda-exec is pointed at a large binary file or a generated data file that happens to have a .py extension. Without this limit, reading a multi-gigabyte file into memory to search for a metadata block would be a denial-of-service risk, particularly in automated pipelines.

Atomic file operations#

Environment creation uses a temporary directory with atomic rename to prevent two categories of problems:

If the download or extraction fails midway, the temporary directory is cleaned up. No other process can observe a half-built environment at the final cache path, because the final path only appears after the atomic rename succeeds.

If two processes attempt to create the same environment simultaneously, only one rename succeeds. The other process detects the existing valid environment and uses it. This prevents corruption from concurrent writes without requiring explicit file locking, which is notoriously unreliable across platforms and filesystems.

Temporary directories are prefixed with .tmp- and are explicitly excluded from cache enumeration in list_cached(), so they never appear in --list output even if a crash leaves one behind.

No shell activation by default#

When conda-exec runs a tool, it does not perform full conda activation by default. It only prepends the environment’s bin/ directory to PATH:

env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")

Full activation, triggered by the --activate flag, runs conda’s activation logic, which executes activation scripts, sets environment variables like CONDA_PREFIX and CONDA_DEFAULT_ENV, and modifies the shell environment in ways that packages can customize through their activate.d/ scripts.

The default PATH-only mode avoids running activation scripts from packages. Most CLI tools (linters, formatters, compilers) do not need activation and work correctly with just PATH. By making activation opt-in, conda-exec reduces the surface area of code that runs by default. Users who need activation (for tools that depend on CONDA_PREFIX or package-specific environment variables) can explicitly request it.

Environment isolation#

Each unique combination of specs and channels produces a separate cached environment. Environments are never shared between different spec combinations, even if one is a subset of another. conda exec ruff and conda exec ruff --with black create two independent environments.

This prevents interference between tool invocations. A tool cannot be affected by packages that were installed for a different invocation, and removing one cached environment has no effect on others.

The isolation also means that cached environments are self-contained conda prefixes. They have their own conda-meta/, their own package records, and their own binary directories. There is no shared state between environments beyond the filesystem directory that contains them.

Summary of guarantees#

Threat

Mitigation

Symlink escape from prefix

is_within_prefix() resolves and validates binary paths

Shell injection via tool name or args

subprocess.run with list arguments, never shell=True

Path traversal via cache key

Regex validation + resolved path is_relative_to() check

Partial environment on crash

Atomic tempfile.mkdtemp + os.rename

Memory exhaustion from large scripts

10 MB file size limit on script parsing

Activation script side effects

PATH-only mode by default, activation opt-in via --activate

Cross-environment interference

Fully isolated prefixes per spec combination

Concurrent environment creation

Atomic rename with fallback to existing environment