# Shadowing the Standard Library

I was reading [Johann Rehberger's write-up](https://embracethered.com/blog/posts/2026/breaking-claude-code-opus-5-and-automode/) of an attack on a coding agent last week. The payload is a file called `struct.py` inside a zip archive. The agent unpacks the archive, writes its own small Python decoder in the extracted directory, and runs it. The decoder does `import base64`, `base64` internally does `import struct`, and Python resolves that to the attacker's file because the script's directory is the first entry on `sys.path`. Module-level code runs on import. The poisoned module re-exports the real `_struct` API so decoding still produces valid output and execution continues, and the payload relaunches itself with `python3 -I` so the second interpreter starts with a clean module path.

That `-I` is the tell that this is a well-understood Python behaviour. Putting an ambient directory on the module search path is a default most dynamic-language runtimes shipped with early on, and several have since removed it, so I went through the ones I could find to see where each ended up. The problem is specific to resolving module lookups at run time against filesystem locations, so languages that bind everything at build time are mostly absent.

## Removed

Ruby dropped `.` from `$LOAD_PATH` in [1.9.2](https://github.com/ruby/ruby/blob/master/doc/NEWS/NEWS-1.9.2), released August 2010. The NEWS entry is one line, "$: no longer includes the current directory, use require_relative". [`require_relative`](https://docs.ruby-lang.org/en/master/Kernel.html#method-i-require_relative) resolves against the calling file's location, independent of the process working directory, so a script can load its own siblings while keeping the working directory off the search path for every other `require` in the process. There was no CVE and no escape-hatch environment variable; 1.9 was already a compatibility break from 1.8 and the load-path change went in alongside everything else.

Perl removed `.` from the end of `@INC` in [5.26.0](https://perldoc.perl.org/perl5260delta#Removal-of-the-current-directory-(%22.%22)-from-@INC), released May 2017. That one did have a CVE, [CVE-2016-1238](https://www.nntp.perl.org/group/perl.perl5.porters/2016/07/msg238271.html), reported by cPanel: a script that changes directory to `/tmp` and then loads an optional module runs whatever a local user has left at `/tmp/Module.pm`. Perl had traditionally shipped with `.` on `@INC`, and taint mode (`perl -T`) had always stripped it, so the risk was documented long before the fix. 5.26 added [`PERL_USE_UNSAFE_INC=1`](https://perldoc.perl.org/perl5260delta#PERL_USE_UNSAFE_INC) to restore the old behaviour for the transition, and downstream distributions [carried patches for years](https://wiki.gentoo.org/wiki/Project:Perl/Dot-In-INC-Removal) while CPAN modules that depended on `.` being present were fixed one at a time.

## Designed out

Node's `require` [checks core module names first](https://nodejs.org/api/modules.html#core-modules), before `node_modules` or anything else on disk, so `require('http')` returns the built-in even with an `http.js` in the caller's directory. Loading a neighbouring file has always taken an explicit relative path, `require('./http')`. Node 14.18.0 added the [`node:` prefix](https://nodejs.org/api/modules.html#core-modules) so `require('node:http')` bypasses the require cache as well, and some newer built-ins such as `node:test` are only reachable that way. That protection covers core modules only: `require('express')` [searches upward from the caller's directory through each `node_modules`](https://nodejs.org/api/modules.html#loading-from-node_modules-folders), so a `node_modules/express` inside an extracted archive is resolved ahead of any installed copy. Java resolves classes at run time from a classpath, and the [default classpath when neither `-cp` nor `CLASSPATH` is set is `.`](https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html), but the bootstrap class loader finds `java.*` before the application loader searches the classpath, so a `./java/lang/String.class` is unreachable for the same reason a local `http.js` is in Node. Deno dropped the ambient search path entirely: every import is [a URL or a relative path](https://docs.deno.com/runtime/fundamentals/modules/), and a bare specifier outside the import map is an error.

Julia's default [`LOAD_PATH`](https://docs.julialang.org/en/v1/base/constants/#Base.LOAD_PATH) is `["@", "@v#.#", "@stdlib"]`, three symbolic entries that expand to the active project environment, the user's versioned default environment, and the standard library. `@` resolves to whichever `Project.toml` is active, regardless of which directory the process is in, so a script run from an untrusted directory looks up imports in a manifest file. PowerShell's `Import-Module` searches [`$env:PSModulePath`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_psmodulepath), which defaults to the per-user, all-users and `$PSHOME` module directories only, and the shell applies the equivalent rule to executable lookup: running a script in the working directory [requires an explicit `.\` prefix](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_scripts), so a bare name searches only `$env:PATH`.

## Still there

PHP's `.` is the first entry in the default [`include_path`](https://www.php.net/manual/en/ini.core.php#ini.include-path), so `include 'config.php'` picks up a `config.php` in the working directory ahead of one under the PEAR path. Lua's default [`package.path`](https://github.com/lua/lua/blob/v5.4.0/luaconf.h#L208-L219) ends with `./?.lua;./?/init.lua`, so `require "json"` falls through to `./json.lua` after the installed-path entries have all missed. Lua's own standard libraries, `string`, `table`, `math` and the rest, are [registered in `package.loaded` before `require` searches any path](https://github.com/lua/lua/blob/v5.4.0/linit.c), so a `./string.lua` is unreachable and only third-party names are exposed. In both PHP and Lua, dropping the working-directory entry means overriding the full path string, via `php -d include_path=...` or the `LUA_PATH` environment variable.

Python prepends the script's directory as `sys.path[0]` when running a file, the working directory when running `-m`, and an empty string, meaning the working directory, when running `-c` or the REPL. Isolated mode, `-I`, [added in 3.4](https://docs.python.org/3/using/cmdline.html#cmdoption-I), suppresses that entry along with user site-packages and all `PYTHON*` environment variables. 3.11 added a narrower switch, [`-P` and `PYTHONSAFEPATH`](https://docs.python.org/3/using/cmdline.html#cmdoption-P), which drops only the `sys.path[0]` entry and leaves the rest of the environment alone. The tracker issue behind `-P`, [bpo-13475](https://github.com/python/cpython/issues/57684), was opened in November 2011. Victor Stinner, who landed `-P`, has a [draft PEP](https://github.com/vstinner/misc/blob/main/cpython/pep_path0.rst) in his personal repo proposing to make safe-path the default and citing Perl 5.26 as precedent. Ruby could drop `.` because `require_relative` shipped in the same release and gave scripts another way to load their siblings. Python's [explicit relative imports](https://docs.python.org/3/reference/import.html#package-relative-imports) only work inside a package, so a standalone `script.py` importing `helper.py` from the same directory depends on `sys.path[0]` being that directory, and running it under `-P` fails with `ModuleNotFoundError`.

Perl, Ruby and Lua all put `.` last, after the standard-library and site directories, so a file in the working directory could only supply a name that was missing everywhere else, and CVE-2016-1238 accordingly needed a target script that loaded an optional module. Python and PHP put the ambient directory first. The standard library comes after it on the path, `struct.py` in the working directory is loaded in place of the real `struct`, and any standard-library module read from disk is exposed the same way. Modules compiled into the interpreter and, [since 3.11](https://docs.python.org/3/whatsnew/3.11.html#faster-startup), the frozen startup set (`os`, `abc`, `io`, `codecs` among them) are served by `BuiltinImporter` and `FrozenImporter` on `sys.meta_path` before `PathFinder` touches disk; `struct` is a plain `.py` wrapper around a C extension and goes to `PathFinder`.

| | Entry on default path | Position | Stdlib shadowable | Removed in | Safe-mode switch |
| --- | --- | :---: | :---: | :---: | --- |
| Ruby | `.` in `$:` | last | no | 1.9.2 (2010) | |
| Perl | `.` in `@INC` | last | no | 5.26 (2017) | (`PERL_USE_UNSAFE_INC` restores) |
| Node.js | none | | no | | |
| Java | `.` when `-cp` unset | | no | | any `-cp` replaces it |
| Deno | none | | no | | |
| Julia | none (project manifest) | | no | | |
| Lua | `./?.lua` in `package.path` | last | no | | |
| PHP | `.` in `include_path` | first | yes | | |
| Python | script dir / cwd in `sys.path` | first | yes | | `-I` (3.4), `-P` / `PYTHONSAFEPATH` (3.11) |

## Threat model

The Perl 5.26 release notes describe the risk as a script loading an optional module "when its current directory is untrusted (such as `/tmp`)", and the mitigation for the twenty years before that was procedural: system scripts `chdir` somewhere safe before loading anything optional, and users run tools only from directories they control, advice written for a person or a fixed-cwd daemon choosing where the interpreter runs.

A coding agent that downloads an archive, extracts it, writes a helper next to the contents, and runs the helper is the `/tmp` case with a different label on the directory. The archive determined the directory contents, and the agent's own script is what triggers the import. Python is also the language agents default to for generated helpers: the sandboxed runtime for both OpenAI's [Code Interpreter](https://developers.openai.com/api/docs/guides/tools-code-interpreter) and Anthropic's [code execution tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool), and the interpreter Claude Code, the target in the write-up, invokes for ad-hoc scripts. In Rehberger's version the shadowed module forwards to the real one, so the decoder returns a correct result. It is the same class of default as the [override flags a subprocess can set through its environment](/2026/08/25/hardening-the-override-flag.html): tolerable while a person supplied the working directory or the flag, and an agent processing an archive supplies the working directory as a side effect of extracting it.

---

By Andrew Nesbitt (https://nesbitt.io). Markdown version of https://nesbitt.io/2026/09/15/shadowing-the-standard-library.html
