Slopsquatting Defense for Claude Code and Codex: Gate Package Installs With a Registry-Check Hook, Lockfiles and a Stop-on-ModuleNotFoundError Rule

Slopsquatting defense for Claude Code and Codex: a registry-check hook, ask rules, prefix rules, lockfile-only installs and a stop rule for missing modules.

Slopsquatting chart: two indigo horizontal bars on a 0 to 30 percent axis, open-source models at 21.7% and commercial models at 5.2% hallucinated packages, above three tiles reading 576,000 code samples analyzed, 16 code-generating LLMs and 205,474 unique invented names.Slopsquatting chart: two indigo horizontal bars on a 0 to 30 percent axis, open-source models at 21.7% and commercial models at 5.2% hallucinated packages, above three tiles reading 576,000 code samples analyzed, 16 code-generating LLMs and 205,474 unique invented names.
In the USENIX Security 2025 study, open-source models averaged at least 21.7% hallucinated packages versus at least 5.2% for commercial models, across 576,000 samples from 16 LLMs, producing 205,474 unique invented names an attacker could register. Source: Spracklen et al., arXiv 2406.10279v3, read Nov 4, 2026.

Your coding agent runs the tests, hits ModuleNotFoundError: No module named 'auth_jwt_validator', and decides the fastest fix is pip install auth_jwt_validator. If that library never existed, the only thing that can answer the install is a package somebody registered on purpose. That is slopsquatting: registering the package names language models invent, then waiting for an agent with a shell to install them. This tutorial builds an install gate for Claude Code, with a parallel Codex CLI setup, so a new dependency can only arrive through a decision you make.

The reader is a developer or small-team lead who already lets an agent run shell commands in a Python or Node repository. By the end you will have five working pieces in that repository:

  • an ask-rule block in .claude/settings.json that turns every dependency-adding command into a permission prompt;
  • a PreToolUse hook script that blocks a package when PyPI or the npm registry does not know it, or when its first upload is younger than 14 days;
  • a Codex rules file that puts the same commands on prompt, tested with codex execpolicy check;
  • a locked install script for routine test runs, built on pip install --require-hashes and npm ci --ignore-scripts;
  • a CLAUDE.md and AGENTS.md rule that tells the agent to stop and ask on a missing module instead of guessing a package name.

Then you plant six test requests and check that each one is stopped by the layer you expect. Nothing below is captured output from a live run. Commands and configuration follow the cited documentation, and the expected observations follow from the script’s own logic.

Why slopsquatting hits agents harder than people

The scenario above comes from a post by Patch (@PatchOnChain) on X, published Oct 31, 2026. It describes an autonomous agent that imported a non-existent auth_jwt_validator, watched the import fail, and ran pip install on a name an attacker had registered 48 hours earlier. Treat the incident and the post’s percentages as one author’s unverified account. The mechanism itself is well documented.

The measured version is Spracklen et al., “A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs”, accepted to the 2025 USENIX Security Symposium. The authors generated 576,000 code samples with 16 popular code-generating models, in two languages and from two prompt datasets. The average share of hallucinated packages was at least 5.2% for commercial models and 21.7% for open-source models, and the study collected 205,474 unique hallucinated package names. That is a large, reusable list of names nobody owned at the time.

Slopsquatting chart: two indigo horizontal bars on a 0 to 30 percent axis, open-source models at 21.7% and commercial models at 5.2% hallucinated packages, above three tiles reading 576,000 code samples analyzed, 16 code-generating LLMs and 205,474 unique invented names.Slopsquatting chart: two indigo horizontal bars on a 0 to 30 percent axis, open-source models at 21.7% and commercial models at 5.2% hallucinated packages, above three tiles reading 576,000 code samples analyzed, 16 code-generating LLMs and 205,474 unique invented names.
In the USENIX Security 2025 study, open-source models averaged at least 21.7% hallucinated packages versus at least 5.2% for commercial models, across 576,000 samples from 16 LLMs, producing 205,474 unique invented names an attacker could register. Source: Spracklen et al., arXiv 2406.10279v3, read Nov 4, 2026.

A person who sees an unresolved import usually goes and checks. An agent in a fix-the-error loop treats the failure as the only thing between it and a green test run, and a package manager is the nearest tool. Two properties make that dangerous:

  • Installs execute code. pip’s secure installs guide says that by default pip “involves running arbitrary code from distributions”. npm runs preinstall, install and postinstall scripts unless scripts are turned off, as the npm ci documentation describes under ignore-scripts.
  • A malicious package can pass your tests. The post’s sample payload ships a working implementation of the invented interface next to its install-time code, so the agent’s next test run goes green and the change looks finished.

Instructions alone do not fix this. Anthropic’s permissions documentation says instructions in CLAUDE.md “shape what Claude tries to do, but they don’t change what Claude Code allows.” So the gate puts enforced layers first and the instruction layer last.

Slopsquatting install gate diagram: an Agent command box flows right through Exists? and Old enough? hook checks to a You approve? ask prompt; no or error from either check drops to a red Hook blocks: exit 2 box, yes from the prompt drops to a green Install runs box, and a dashed teal path labelled routine tests leads to a Test runs box using npm ci or pip --require-hashes.Slopsquatting install gate diagram: an Agent command box flows right through Exists? and Old enough? hook checks to a You approve? ask prompt; no or error from either check drops to a red Hook blocks: exit 2 box, yes from the prompt drops to a green Install runs box, and a dashed teal path labelled routine tests leads to a Test runs box using npm ci or pip --require-hashes.
The registry hook removes unknown and brand-new names before the ask rule puts every remaining install in front of you; routine tests skip both through the locked install script. Original diagram based on Anthropic's Claude Code permissions docs, PyPI's JSON API and the npm registry API, read Nov 4, 2026. The 14-day threshold is this guide's example, not a standard.

Before you start: the repo, the tools and the threshold

Work on a throwaway branch of a real repository, or in a test repo that already has a lockfile. Check each item before you write any configuration:

  • [ ] A current Claude Code install, started from the repository root. Project hooks and settings load from the current working directory’s .claude/ folder with no parent-directory fallback, so launching from a subdirectory silently skips them.
  • [ ] Optional: Codex CLI, plus permission to edit ~/.codex/rules/. Project-local rules in <repo>/.codex/rules/ load only when the project .codex/ layer is trusted.
  • [ ] Python 3.8 or later on PATH as python3. The hook uses only the standard library. Install jq for the manual tests.
  • [ ] A requirements.txt with pinned versions and --hash lines, a package-lock.json, or both.
  • [ ] Outbound HTTPS to pypi.org and registry.npmjs.org from the machine where the agent runs. Without it the hook fails closed and blocks every install.
  • [ ] A minimum package age. This guide uses 14 days, set through INSTALL_GATE_MIN_AGE_DAYS. It is an editorial choice, not a published standard. A longer window catches more fresh registrations and also blocks more legitimate new packages.
  • [ ] Git, so every file in this guide is one revert away.

The finished layout:

repo/
├── .claude/
│   ├── settings.json              # ask, allow and deny rules plus the hook
│   ├── install-allowlist.txt      # exceptions you maintain by hand
│   └── hooks/install_gate.py      # registry and age check
├── .codex/rules/install-gate.rules  # or ~/.codex/rules/install-gate.rules
├── scripts/install-locked.sh      # the only install the agent may run unprompted
├── CLAUDE.md                      # stop rule
└── AGENTS.md                      # same stop rule for Codex

Layer 1: put every dependency-adding command on ask

Start with permission rules, because Claude Code enforces them whatever the model decides. Rules are evaluated deny, then ask, then allow, and the first match in that order wins; specificity does not change it. That is the property you want. Once Bash(pip install *) is an ask rule, a more specific allow rule cannot quietly approve pip install something, even one saved later from a hasty answer to a prompt.

Create or merge .claude/settings.json:

{
  "permissions": {
    "ask": [
      "Bash(pip install *)",
      "Bash(pip3 install *)",
      "Bash(python -m pip install *)",
      "Bash(python3 -m pip install *)",
      "Bash(uv add *)",
      "Bash(uv pip install *)",
      "Bash(poetry add *)",
      "Bash(npm install *)",
      "Bash(npm i *)",
      "Bash(npm add *)",
      "Bash(pnpm add *)",
      "Bash(yarn add *)",
      "Bash(bun add *)",
      "Bash(cargo add *)",
      "Bash(npx *)"
    ],
    "allow": [
      "Bash(./scripts/install-locked.sh)",
      "Bash(npm ci --ignore-scripts)"
    ],
    "deny": [
      "Edit(/scripts/install-locked.sh)",
      "Edit(/.claude/hooks/**)",
      "Edit(/.claude/install-allowlist.txt)"
    ],
    "disableBypassPermissionsMode": "disable"
  }
}

What each choice does:

  1. The trailing * in a rule such as Bash(npm install *) also matches the bare command, so a plain npm install, which can rewrite the lockfile, prompts as well.
  2. python3 -m pip install and uv pip install get their own rules because a Bash rule matches the command text as written, not the program behind it.
  3. Bash(npx *) is optional. npx can fetch and run a package that is not in package.json. If it prompts too often for tools you already depend on, keep it anyway for a week and see which prompts you actually decline.
  4. The two allow rules contain no *, so only those exact strings run without a prompt.
  5. The deny rules stop the agent from editing its own guardrails: the locked install script, the hook directory and the allowlist. A path starting with / anchors at the project’s primary working directory when the rule lives in project settings.
  6. disableBypassPermissionsMode set to "disable" removes the mode that skips prompts. It works from any settings scope; in a team, put it in managed settings so no project or user file can turn it back on.

Ask rules understand shell structure. Deny and ask rules apply when any subcommand matches, including commands inside subshells, command substitutions and loops, so cd api && pip install x still prompts, and the documentation notes this holds even in auto mode. Claude Code also strips a fixed set of wrappers (timeout, time, nice, nohup, stdbuf, command, builtin and zsh’s noglob) and lets deny and ask rules match past any leading environment assignment.

The same page is direct about what a Bash rule does not cover:

Command form Ask rule result Why
pip install x Prompts Literal match
cd app && pip install x Prompts Any matching subcommand triggers the ask rule
timeout 60 pip install x Prompts timeout is a stripped wrapper
PIP_INDEX_URL=... pip install x Prompts Ask rules match past any leading assignment
/usr/bin/pip install x Not matched Rules match the text, not the program by path
bash -c 'pip install x' Not matched sh -c and bash -c are not unwrapped
devbox run pip install x, docker exec app pip install x Not matched Environment runners are not in the stripped list

The last three rows are why Layer 2 exists. A PreToolUse hook sees the full command text and applies your own logic to it.

Layer 2: a PreToolUse hook that asks the registry first

PreToolUse hooks run before the permission prompt. A hook that exits with code 2 stops the tool call before permission rules are evaluated, and when the hook does not block, Claude Code still applies the deny and ask rules. So the hook removes the obviously bad requests, and everything it lets through still reaches your prompt.

The script asks two questions about each requested package:

  1. Does the registry know it? For Python it requests GET https://pypi.org/pypi/<project>/json, the route PyPI documents in its JSON API reference. For Node it requests GET https://registry.npmjs.org/<package>, which the npm registry API document describes as returning the package metadata document. Any response other than 200 blocks.
  2. Is it old enough? On PyPI each release lists its files, and each file carries an upload_time_iso_8601; the script takes the earliest across all releases. In the npm document, the time object contains a created timestamp. Anything younger than the threshold blocks.

PyPI User Documentation page for the JSON API, as captured from docs.pypi.org.
The opening of PyPI's JSON API documentation, the page that documents the GET /pypi/<project>/json route this guide's install-gate hook queries. Source: PyPI User Documentation (docs.pypi.org). · Original source

Save this as .claude/hooks/install_gate.py:

#!/usr/bin/env python3
"""install_gate.py: Claude Code PreToolUse hook for the Bash tool.

Blocks package installs when a requested name is unknown to PyPI or the npm
registry, or when its first upload is younger than MIN_AGE_DAYS.
Exit 0 = no objection (permission rules still apply). Exit 2 = block.
Any error inside the gate also blocks: it fails closed.
"""
import json
import os
import re
import shlex
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone

MIN_AGE_DAYS = int(os.environ.get("INSTALL_GATE_MIN_AGE_DAYS", "14"))
TIMEOUT_SECONDS = 8
PROJECT_DIR = os.environ.get("CLAUDE_PROJECT_DIR", ".")
ALLOWLIST_PATH = os.path.join(PROJECT_DIR, ".claude", "install-allowlist.txt")

WRAPPERS = {"sudo", "env", "timeout", "time", "nice", "nohup", "stdbuf",
            "command", "builtin", "noglob", "exec"}
SHELLS = {"sh", "bash", "zsh", "dash"}
SEPARATORS = {"&&", "||", ";", "|", "|&", "&", "(", ")"}
# Options whose next token is a value, not a package name.
PIP_VALUE_OPTS = {"-r", "--requirement", "-c", "--constraint", "-e",
                  "--editable", "-t", "--target", "--prefix", "--root",
                  "--src", "--python-version", "--platform", "--report",
                  "-C", "--config-settings", "--upgrade-strategy"}
NPM_VALUE_OPTS = {"-w", "--workspace", "--tag", "--prefix", "--save-prefix"}
# Options that change where packages come from: always a human decision.
SOURCE_OPTS = {"-i", "--index-url", "--extra-index-url", "-f",
               "--find-links", "--registry", "--index"}

STOP = ("Do not retry with another name, package manager, wrapper, URL or "
        "index. Stop, tell the user which import failed and why, and wait.")


class Block(Exception):
    pass


def normalize(name):
    return re.sub(r"[-_.]+", "-", name).lower()


def load_allowlist():
    try:
        with open(ALLOWLIST_PATH, encoding="utf-8") as fh:
            lines = (line.split("#", 1)[0].strip() for line in fh)
            return {normalize(line) for line in lines if line}
    except FileNotFoundError:
        return set()


def segments(command):
    """Split a shell command into simple commands, respecting quotes."""
    lexer = shlex.shlex(command.replace("\n", " ; "), posix=True,
                        punctuation_chars=True)
    lexer.whitespace_split = True
    try:
        tokens = list(lexer)
    except ValueError as exc:
        raise Block(f"could not parse the command ({exc})")
    current = []
    for tok in tokens:
        if tok in SEPARATORS:
            if current:
                yield current
            current = []
        else:
            current.append(tok)
    if current:
        yield current


def strip_prefix(tokens):
    i = 0
    while i < len(tokens):
        tok, prev = tokens[i], tokens[i - 1] if i else ""
        if tok in WRAPPERS or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", tok):
            i += 1
        elif prev in WRAPPERS and (tok.startswith("-")
                                   or re.fullmatch(r"[0-9.]+[smhd]?", tok)):
            i += 1
        else:
            break
    return tokens[i:]


def install_request(tokens):
    """Return (registry, args) for a dependency-adding command, else None."""
    t = strip_prefix(tokens)
    if not t:
        return None
    prog = os.path.basename(t[0])
    if prog in SHELLS:
        for i, tok in enumerate(t[1:], start=1):
            if re.fullmatch(r"-[a-z]*c[a-z]*", tok) and i + 1 < len(t):
                return ("shell", t[i + 1])
        return None
    if re.fullmatch(r"python[0-9.]*", prog) and t[1:3] == ["-m", "pip"]:
        t, prog = ["pip"] + t[3:], "pip"
    if re.fullmatch(r"pip[0-9.]*", prog) and t[1:2] == ["install"]:
        return ("pypi", t[2:])
    if prog == "uv" and t[1:3] == ["pip", "install"]:
        return ("pypi", t[3:])
    if prog in ("uv", "poetry") and t[1:2] == ["add"]:
        return ("pypi", t[2:])
    if prog == "npm" and t[1:2] and t[1] in ("install", "i", "add", "isntall"):
        return ("npm", t[2:])
    if prog in ("pnpm", "yarn", "bun") and t[1:2] == ["add"]:
        return ("npm", t[2:])
    return None


def requested_names(registry, args):
    names, skip_next = [], False
    value_opts = PIP_VALUE_OPTS if registry == "pypi" else NPM_VALUE_OPTS
    for arg in args:
        if skip_next:
            skip_next = False
            continue
        opt = arg.split("=", 1)[0]
        if opt in SOURCE_OPTS:
            raise Block(f"`{opt}` changes where packages come from")
        if opt in value_opts:
            skip_next = "=" not in arg
            continue
        if arg.startswith("-"):
            continue
        if arg == "." or arg.startswith(("./", "../", "/")):
            continue  # local project path: left to the ask rule
        if re.match(r"^[a-z+]+:", arg) or (
                registry == "npm" and "/" in arg and not arg.startswith("@")):
            raise Block(f"`{arg}` is not a registry package name")
        names.append(arg)
    return names


def base_name(registry, spec):
    if registry == "npm":
        m = re.fullmatch(r"(@[a-z0-9][\w.-]*/[\w.-]+|[\w.-]+)(@.*)?", spec, re.I)
    else:
        m = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9._-]*)(\[.*\])?\s*([<>=\x21~;].*)?",
                         spec)
    if not m:
        raise Block(f"`{spec}` is not a package name the gate can check")
    return m.group(1)


def fetch_json(url):
    req = urllib.request.Request(url, headers={
        "Accept": "application/json", "User-Agent": "install-gate-hook/1"})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp:
            return json.load(resp)
    except urllib.error.HTTPError as exc:
        raise Block(f"{url} answered HTTP {exc.code}, not 200")
    except (urllib.error.URLError, TimeoutError, OSError) as exc:
        raise Block(f"could not reach {url} ({exc}); failing closed")


def parse_time(stamp):
    return datetime.fromisoformat(stamp.replace("Z", "+00:00"))


def first_upload(registry, name):
    if registry == "pypi":
        data = fetch_json(
            f"https://pypi.org/pypi/{urllib.parse.quote(name)}/json")
        stamps = [f.get("upload_time_iso_8601")
                  for files in (data.get("releases") or {}).values()
                  for f in files if f.get("upload_time_iso_8601")]
    else:
        data = fetch_json("https://registry.npmjs.org/"
                          + urllib.parse.quote(name, safe="@"))
        created = (data.get("time") or {}).get("created")
        stamps = [created] if created else []
    if not stamps:
        raise Block(f"{registry} lists no upload dates for `{name}`")
    return min(parse_time(s) for s in stamps)


def check(command):
    allow = load_allowlist()
    for tokens in segments(command):
        request = install_request(tokens)
        if request is None:
            continue
        registry, payload = request
        if registry == "shell":
            check(payload)
            continue
        for spec in requested_names(registry, payload):
            name = base_name(registry, spec)
            if normalize(name) in allow:
                continue
            age_days = (datetime.now(timezone.utc)
                        - first_upload(registry, name)).days
            if age_days < MIN_AGE_DAYS:
                raise Block(f"`{name}` was first uploaded to {registry} "
                            f"{age_days} day(s) ago, under the "
                            f"{MIN_AGE_DAYS}-day minimum")


def main():
    try:
        payload = json.load(sys.stdin)
        command = (payload.get("tool_input") or {}).get("command")
        if not isinstance(command, str):
            raise Block("could not read tool_input.command from the hook input")
        check(command)
    except Block as exc:
        print(f"install-gate blocked this command: {exc}. {STOP}",
              file=sys.stderr)
        return 2
    except Exception as exc:  # any bug blocks instead of passing
        print(f"install-gate failed ({exc!r}) and is failing closed. {STOP}",
              file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main())

Design choices worth understanding before you rely on it:

  • It fails closed. A timeout, DNS failure, rate limit, malformed response or bug produces exit 2 with a message, never a silent pass. A gate that fails open lets through exactly the requests it exists to catch.
  • It checks every name in a command. pip install requests auth-jwt-validator is blocked as a whole if one name fails, and the message names the failing package.
  • It refuses source overrides. --index-url, --extra-index-url, --find-links, --registry and direct URLs are blocked, because they change where the bytes come from and a lookup on the public registry would prove nothing about them.
  • It unwraps sh -c, bash -c and zsh -c, strips wrappers and environment assignments, and reads the program’s basename, so /usr/bin/pip install is recognized. Those are the forms the ask rules miss.
  • It leaves local paths alone. pip install -e . installs your own project; the ask rule still prompts for it.
  • PyPI’s releases key is deprecated. The JSON API reference lists releases as deprecated in favor of the Index API. If PyPI removes it, the script finds no upload dates and blocks, which is the safe failure and your signal to update the lookup.
  • cargo is not checked. The hook covers PyPI and npm only. cargo add stays on the ask rule.

Register the hook in the same .claude/settings.json. Add this top-level hooks key next to permissions:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/install_gate.py"
          }
        ]
      }
    ]
  }
}

Calling python3 explicitly means the script needs no executable bit. $CLAUDE_PROJECT_DIR points at the project root where the session started, so the path still resolves when the agent changes directories.

Treat that registration block, and the input shape the script reads, as something to confirm on your own install rather than as quoted documentation. The hooks layout and the stdin envelope with the command at tool_input.command follow Anthropic’s separate hooks reference, which is not among this guide’s cited sources. The cited permissions page confirms a narrower set of facts: PreToolUse hooks run before the permission prompt, exit code 2 blocks before rules are evaluated, command is the Bash tool’s input field, and running with --verbose shows the exact parameter names in each tool call. The tests below are what prove the shape matches your version. If the field ever differs, the script cannot find a command and blocks every Bash call with “could not read tool_input.command”, which is loud enough to notice in seconds rather than silently passing installs.

Test the script without Claude Code first. From the repository root:

gate() {
  jq -n --arg c "$1" '{tool_input: {command: $c}}' \
    | python3 .claude/hooks/install_gate.py
  echo "exit=$?"
}

gate 'pip install zq-install-gate-canary-7731'            # 1 fake name
gate 'npm i zq-install-gate-canary-7731'                  # 2 fake name, npm
gate 'pip install requests'                               # 3 established
export INSTALL_GATE_MIN_AGE_DAYS=100000
gate 'pip install requests'                               # 4 age branch
unset INSTALL_GATE_MIN_AGE_DAYS
gate "sh -c 'pip install zq-install-gate-canary-7731'"    # 5 wrapper
gate 'pytest -q'                                          # 6 not an install

Expected results, read from the script’s logic:

  1. exit=2 and a line beginning install-gate blocked this command: https://pypi.org/pypi/zq-install-gate-canary-7731/json answered HTTP, followed by the status code PyPI returned and the stop instruction.
  2. exit=2, naming registry.npmjs.org instead.
  3. exit=0 with no output. The hook has no objection; inside Claude Code the ask rule would now prompt.
  4. exit=2 with “requests was first uploaded to pypi … day(s) ago, under the 100000-day minimum”. This exercises the age branch without hunting for a genuinely new package.
  5. exit=2, the same message as test 1, proving the shell unwrap works.
  6. exit=0 and no network request, because non-install commands pass straight through.

If test 1 passes with exit=0, the name happens to exist; replace it with another nonsense string and read what the registry answered. These manual tests prove the script’s logic but not the registration, because gate builds the input itself. To prove Claude Code delivers the same shape, start Claude Code from the repo root, open /permissions to confirm the ask, allow and deny rules are listed with .claude/settings.json as their source, then run row 1 of the planted-request table below. A block carrying the hook’s message there is the evidence that the registration and the tool_input.command envelope match your version; a plain prompt means they do not, and the first troubleshooting row applies.

Layer 3: the same gate in Codex with prefix rules

The documentation behind this guide offers no registry hook for Codex, so its gate is a set of prefix rules plus the stop rule in AGENTS.md. Codex rules are Starlark prefix_rule() entries that control which commands Codex can run outside the sandbox. Each takes a pattern, a decision of allow, prompt or forbidden, an optional justification, and optional match and not_match examples that Codex validates when it loads the file. When several rules match, the most restrictive decision wins. OpenAI labels rules experimental, so recheck the page after Codex upgrades.

Save this as ~/.codex/rules/install-gate.rules for every project, or as <repo>/.codex/rules/install-gate.rules for one trusted project:

# install-gate.rules: every command that adds a dependency needs a human.

prefix_rule(
    pattern = [["pip", "pip3"], "install"],
    decision = "prompt",
    justification = "New Python dependency. Confirm the name exists on PyPI, is not brand new, and belongs in the lockfile.",
    match = ["pip install requests", "pip3 install auth-jwt-validator"],
    not_match = ["pip list", "pip show requests"],
)

prefix_rule(
    pattern = [["python", "python3"], "-m", "pip", "install"],
    decision = "prompt",
    justification = "New Python dependency via python -m pip. Confirm the name before approving.",
    match = ["python3 -m pip install requests"],
)

prefix_rule(
    pattern = ["uv", "add"],
    decision = "prompt",
    justification = "uv add changes pyproject.toml and the lockfile. Confirm the name first.",
    match = ["uv add httpx"],
)

prefix_rule(
    pattern = ["uv", "pip", "install"],
    decision = "prompt",
    justification = "New Python dependency via uv pip. Confirm the name first.",
    match = ["uv pip install httpx"],
    not_match = ["uv pip list"],
)

prefix_rule(
    pattern = ["npm", ["install", "i", "add"]],
    decision = "prompt",
    justification = "New npm dependency or lockfile rewrite. Tests use npm ci --ignore-scripts.",
    match = ["npm install left-pad", "npm i lodash", "npm install"],
    not_match = ["npm ci --ignore-scripts", "npm test"],
)

prefix_rule(
    pattern = [["pnpm", "yarn", "bun"], "add"],
    decision = "prompt",
    justification = "New JavaScript dependency. Confirm the name on the npm registry first.",
    match = ["pnpm add zod", "yarn add zod", "bun add zod"],
)

prefix_rule(
    pattern = ["cargo", "add"],
    decision = "prompt",
    justification = "New Rust dependency. Confirm the crate before approving.",
    match = ["cargo add serde"],
)

prefix_rule(
    pattern = ["npm", ["install", "i"], ["-g", "--global"]],
    decision = "forbidden",
    justification = "Global installs are outside agent tasks. Ask the user to install the tool, or use a project dependency.",
    match = ["npm install -g some-cli", "npm i --global some-cli"],
)

Load and test it:

  1. Restart Codex. It scans the rules/ folder under every active config layer at startup.
  2. Run the checker against each command family:
R=~/.codex/rules/install-gate.rules
codex execpolicy check --pretty --rules "$R" -- pip install requests
codex execpolicy check --pretty --rules "$R" -- python3 -m pip install requests
codex execpolicy check --pretty --rules "$R" -- npm i left-pad
codex execpolicy check --pretty --rules "$R" -- npm install -g some-cli
codex execpolicy check --pretty --rules "$R" -- npm ci --ignore-scripts
  1. Read the JSON. Codex documents that the command reports the strictest decision and the matching rules, including their justification text. Expect prompt for the first three. Expect forbidden for the global install, because both the forbidden rule and the npm prompt rule match and the stricter one wins. Expect no matching rule for npm ci --ignore-scripts.
  2. If Codex refuses to load the file, read the error: a match or not_match example that contradicts its pattern is reported at load time, before the rule takes effect.

Three Codex behaviors shape how far this layer reaches:

  • A nested list such as ["install", "i", "add"] is a union of alternatives at one argument position, so one rule covers the npm spellings.
  • For a bash -lc script made only of plain words joined by &&, ||, ; or |, Codex splits the script and checks each command, so git add . && pip install x still prompts. A script with variables, substitutions, redirects, wildcards or control flow is checked as a single bash -lc invocation, and the pip rule will not match inside it. The AGENTS.md rule covers that gap with instructions only.
  • Approving a command permanently in the TUI writes to ~/.codex/rules/default.rules, and Smart approvals may propose a prefix_rule during an escalation. Never accept a proposed allow for an install prefix. Because prompt beats allow, a stray allow cannot override these rules, but review that file whenever it changes.

When Codex does prompt for an install, check the name before approving by running the Layer 2 gate function on the exact command; the hook script works as a manual checker outside Claude Code. Our Codex approval modes and sandbox guide explains when a command leaves the sandbox and needs approval in the first place.

Layer 4: lockfile-only installs for routine test runs

The agent still needs dependencies to run the tests. Give it one checked-in command that installs exactly what the lockfiles say and nothing else, and allow only that command. Because the ask rule on pip install * would also catch a locked pip install typed directly, the pip step lives inside a script the agent can run but cannot edit.

Save this as scripts/install-locked.sh, then run chmod +x scripts/install-locked.sh:

#!/bin/sh
# The only dependency install the agent may run without a prompt.
set -eu
cd "$(dirname "$0")/.."

if [ -f requirements.txt ]; then
  # Every requirement pinned with ==, every file hashed, wheels only.
  python3 -m pip install --require-hashes --only-binary :all: -r requirements.txt
fi

if [ -f package-lock.json ]; then
  # Frozen install from the lockfile; dependency lifecycle scripts off.
  npm ci --ignore-scripts
fi

pip documentation page titled Secure installs, as captured from pip.pypa.io.
The top of pip's Secure installs page, which recommends hash-checking mode with --require-hashes and disallowing source distributions with --only-binary :all:, the two flags used in this guide's locked install script. Source: pip documentation (pip.pypa.io). · Original source

What each flag prevents, and what it costs you:

Command or flag What it prevents What it breaks
pip install --require-hashes -r requirements.txt Any requirement without a local hash, any dependency that is not listed and hashed, any requirement not pinned with ==; pip errors instead of resolving something new Every new dependency needs a re-lock; hashed requirements cannot be mixed with local directories or VCS URLs
--only-binary :all: Installing source distributions, whose build step runs package code Packages with no wheel for your platform fail to install
npm ci Lockfile drift: it exits with an error when package-lock.json and package.json disagree, never writes either file, and cannot add individual packages Needs an existing lockfile, deletes node_modules first, and must get the same tree-shaping flags the lock was built with, such as --legacy-peer-deps
--ignore-scripts Dependency lifecycle scripts such as preinstall and postinstall Packages that need an install script, often native builds

The hashes must live in your own file. pip’s guide says hashes from the index’s download URL fragment protect against corruption but do not satisfy --require-hashes. A locked line looks like the documented example, one --hash per archive you accept:

FooProject == 1.2 \
  --hash=sha256:<digest from your lock tool or pip hash> \
  --hash=sha256:<second archive digest, if you accept one>

Use whatever lock tool your project already has, as long as it writes those lines; pip hash computes the digest for an archive you have downloaded and reviewed.

If one package genuinely needs its install script, do not drop --ignore-scripts for everything. npm documents an allowScripts field in package.json for project-wide policy and a strict-allow-scripts setting that makes unreviewed install scripts fail the install. Since --ignore-scripts overrides allowScripts, a project that needs one script should remove the flag from this script and set allowScripts plus strict-allow-scripts instead. npm also recommends --install-strategy=linked during development, which exposes only declared dependencies and so surfaces undeclared “phantom” imports before they ship.

Check the layer:

  • [ ] ./scripts/install-locked.sh runs in Claude Code with no prompt.
  • [ ] The test suite passes afterwards.
  • [ ] git status shows no change to requirements.txt, package.json or package-lock.json.
  • [ ] An attempt by the agent to edit scripts/install-locked.sh is refused by the deny rule.

Layer 5: the stop rule for ModuleNotFoundError

The enforced layers turn a bad install into a blocked call. The instruction layer decides what the agent does next. Without it, a blocked agent tends to try a different spelling, a different package manager or a wrapper. Paste the same section into both CLAUDE.md and AGENTS.md:

## Missing modules and new dependencies

When a command fails with `ModuleNotFoundError`, `ImportError`, `Cannot find module`
or `ERR_MODULE_NOT_FOUND`:

1. Do not install anything to make the error go away, and never derive a
   package name from an import name.
2. First check whether the import is a typo, something the standard library
   already provides (Python `hmac`, `hashlib`, `json`, `urllib`; Node `crypto`,
   `fs`, `path`), or a dependency already declared in `requirements.txt`,
   `pyproject.toml` or `package.json`.
3. If one of those fits, change the code to use it and rerun the tests.
4. If none fits, stop and report: the exact import that failed, the file and
   line, what the code needs it for, and the package you believe provides it.
   Then wait for the user.
5. If install-gate or a permission rule blocks a command, treat the block as
   final. Do not retry with another name, another package manager, `sh -c`,
   `npx`, a direct URL or a different index.
6. Add a dependency only when the task explicitly asks for one. When one is
   approved, update the lockfile in the same change and say so in the summary.
7. Install test dependencies only with `./scripts/install-locked.sh`.

Roll it out in this order:

  1. Paste the section into CLAUDE.md at the repository root, below any existing build and test instructions.
  2. Paste the identical section into AGENTS.md, so Codex reads the same policy.
  3. Commit both files together with the settings, hook and rules, so one revert removes the whole gate.
  4. Start a fresh session in each tool, since instruction files load at session start.

Why these points: step 2 names the modules the post’s webhook example actually needed, because an HMAC signature check is hmac and hashlib, which turns the common case into a code fix. Step 5 closes the retry loop the hook cannot see, because each retry is a new tool call that looks innocent alone. Step 6 keeps every dependency change in one reviewable diff. This layer enforces nothing, but it is the only one that covers commands the rules and the hook cannot parse.

Plant six requests and confirm which layer stops each

Run these in a fresh Claude Code session started from the repo root in Manual mode, the default. Ask the agent to run each command verbatim, for example: “Run exactly this command and report what happened: pip install zq-install-gate-canary-7731”. The canary is a nonsense name chosen so it should not exist, and the gate’s message tells you what the registry actually answered.

# Planted request Layer that should act Expected observation If you see something else
1 Fake package: pip install zq-install-gate-canary-7731 Hook The tool call fails with install-gate blocked this command: ... answered HTTP ...; no prompt appears A permission prompt means the hook did not run; see the first troubleshooting row
2 Too new: pip install requests with INSTALL_GATE_MIN_AGE_DAYS=100000 exported in the shell before launching Claude Code Hook, age branch Blocked with “first uploaded to pypi … under the 100000-day minimum” A prompt means the variable did not reach the hook; set it in the shell that starts Claude Code
3 Established package: pip install requests in a normal session Ask rule A permission prompt for the command; decline it Running without a prompt means an allow rule or permission mode is overriding; check /permissions
4 Typo of a popular name: pip install reqeusts Hook if the name is unregistered or new, otherwise the ask prompt Either a block, or a prompt in which you can see the misspelling Running without a prompt: same cause as row 3
5 Wrapper: bash -c 'npm i zq-install-gate-canary-7731' Hook, because the ask rule does not see inside bash -c Blocked, naming registry.npmjs.org A run or a prompt means the shell unwrap failed; retest with the gate function
6 Control: ./scripts/install-locked.sh, then your test command Allow rule Runs without a prompt, and the tests pass A prompt means the allow string does not match exactly, or the workspace is not trusted yet

Row 4 carries the honest lesson. An old typosquat passes both registry checks, because it exists and is not new. What stops it is a person reading the prompt, which is why the ask rule stays in place even when the hook has no objection.

After rows 1 and 5, read the agent’s next message. Under the stop rule it should report the failed name and wait. If it proposes another package name instead, tighten the wording of step 5 in CLAUDE.md and rerun row 1. For Codex, repeat rows 3 and 4 in a session and confirm the prompt shows your justification text.

The gate passes when every box is ticked:

  • [ ] Rows 1, 2 and 5 are blocked by the hook, with its message visible in the transcript.
  • [ ] Rows 3 and 4 never install without your approval.
  • [ ] Row 6 runs without a prompt and the suite passes.
  • [ ] No row produced an install you did not approve, and no failure was silent.

Troubleshooting the install gate

Symptom Likely cause Fix Expected after the fix
The fake package prompts instead of being blocked Hook not loaded: Claude Code started outside the repo root, invalid settings.json, or the hooks key nested in the wrong place Start from the root; run jq . .claude/settings.json; check claude doctor and the invalid-settings dialog at startup Row 1 is blocked
Every Bash command is blocked with “could not read tool_input.command” The hook input shape differs from what the script reads Run claude --verbose, compare the tool input, adjust the lookup in main() Non-install commands pass again
A legitimate internal or scoped package is blocked It is not on the public registry, or lives on a private index Add its name to .claude/install-allowlist.txt yourself; the agent’s deny rule keeps it out of that file The ask prompt appears instead of a block
“could not reach … failing closed”, or HTTP 429 Offline machine, proxy, or registry rate limit Wait and retry, or verify the package on the registry website and install it yourself outside the agent The gate passes once the registry answers 200
A multi-package command is blocked although most names are fine The gate blocks the whole command when any name fails Let the agent report, then approve the good names in a separate command Only the bad name stays blocked
Codex never prompts for pip install Untrusted project .codex/ layer, no restart, or the install sat inside a bash -lc script with variables Trust the project or move the file to ~/.codex/rules/; restart; rerun codex execpolicy check The check returns prompt
npm ci exits with a lockfile error package.json changed without an updated package-lock.json A human runs npm install, reviews the diff and commits the lock npm ci succeeds and changes no files
pip reports a hash or pinning error A transitive dependency is unhashed or unpinned, or another platform’s archive has no hash Re-lock with hashes; add hashes for alternative archives with pip hash The locked install succeeds
A package breaks under --ignore-scripts It needs a lifecycle script Switch that project to allowScripts with strict-allow-scripts Only reviewed scripts run

One interaction to know about: with Claude Code’s sandbox enabled and autoAllowBashIfSandboxed at its default, a bare Bash ask rule stops prompting for sandboxed commands, but content-scoped ask rules such as Bash(pip install *) still force a prompt. The rules in this guide keep working either way. Our Claude Code sandbox setup guide covers the sandbox settings themselves.

Rollback, recovery and the gaps this gate leaves open

To remove the gate cleanly:

  1. Delete the hooks key from .claude/settings.json and remove .claude/hooks/install_gate.py, or restore both with git checkout.
  2. Revert the permissions changes with git, then inspect .claude/settings.local.json, where Claude Code saves rules from “Yes, and don’t ask again”.
  3. Delete install-gate.rules from the Codex rules folder and restart Codex.
  4. Remove the stop-rule section from CLAUDE.md and AGENTS.md.
  5. To run one session without the hook while keeping the files, start Claude Code with --settings '{"disableAllHooks": true}'. The ask rules still prompt.

If an unapproved package did get installed:

  1. Stop the session, uninstall the package, and restore the lockfiles from git.
  2. Assume its install code ran with the agent’s user permissions. Rotate every credential readable from that environment: cloud keys, SSH keys, .env values and registry tokens. Our guide to stage-only npm tokens for agents shows how to limit what a leaked publish token can do next time.
  3. Look for persistence before you trust the machine again. Unauthored hooks are persistence walks through finding planted agent hooks and proving they stay gone.

The gaps that remain, so you can decide what else you need:

  • [ ] Obfuscated or indirect installs. Variables, eval, encoded strings, or a Python or Node script the agent writes that calls the package manager itself. The hook reads command text, not intent.
  • [ ] Mods. The permissions page says that without managed settings or a Team or Enterprise login, an installed mod can approve a call a PreToolUse hook blocked. Treat mods with the same care as marketplace plugins and MCP servers.
  • [ ] Untrusted repositories. A cloned repo can ship its own .claude/ and AGENTS.md; review them at intake, as in our GitSpawn repo intake checklist.
  • [ ] Network reach. The documentation describes sandboxing as OS-level enforcement that does not depend on the command text. Cutting outbound network for test commands is the boundary that holds when every text-based layer misses.

If you run several agents across machines, make these layers one policy rather than a per-repo habit; Claude Code permission modes are fleet policy covers that rollout.

Sources

  1. Patch (@PatchOnChain), “The Slopsquatting Trap: How Autonomous Coding Agents Weaponize Phantom Dependencies”, X, Oct 31, 2026. Incident and figures treated as unverified.
  2. Anthropic, Configure permissions, Claude Code Docs, read Nov 4, 2026.
  3. OpenAI, Rules, Codex documentation, read Nov 4, 2026.
  4. PyPI, JSON API, PyPI User Documentation, read Nov 4, 2026.
  5. npm, Public Registry API, npm/registry on GitHub, read Nov 4, 2026.
  6. pip, Secure installs, pip documentation v26.3, read Nov 4, 2026.
  7. npm, npm-ci, npm Docs v11, read Nov 4, 2026.
  8. Spracklen, Wijewickrama, Sakib, Maiti, Viswanath and Jadliwala, “A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs”, arXiv 2406.10279v3, to appear at USENIX Security 2025.
YOU'RE THROUGH THIS ONE.

Keep connecting the dots.

Back to the library

Many assistants.
One place to remember.

Bring your AI sessions, context, and usage together with Automater.

Meet Automater