Hallucinated packages and slopsquatting: dependency checks for agent changes
Dependency verification for agent changes means automatic checks that stop a coding agent from adding a package that does not exist, one an attacker registered under a hallucinated or misspelled name, a version published hours ago, or an unapproved license. A CI gate on the lockfile diff enforces it, backed by cooldowns, a registry proxy and an approval hook.
Your agent fixed a timezone bug overnight. Its summary says it “added date-fns-tz-utils for offset handling”, the pull request is green, and the lockfile diff is 1,400 lines that nobody will read. On 26 September 2026 that name did not exist on npm. If models keep suggesting it, someone will register it, and the next agent that suggests it installs whatever they uploaded. This page is for the developer who configures the agent, the tech lead who owns the pipeline, and the CTO who has to show an auditor how new third-party code gets into the product.
What you get from gating agent dependency changes
Section titled “What you get from gating agent dependency changes”- A tested CI gate for npm (
package-lock.json) and Python (uv.lock) that fails on non-existent, too-new, unapproved, unpinned, off-registry and wrongly licensed packages, direct or transitive. - Cooldown settings for npm, pnpm and uv that keep days-old versions out of every install that uses the repository.
- A registry proxy configuration that hides young versions and blocked names for the whole organization.
- Agent-side approval: a Claude Code hook tested against 13 commands, a Codex rules file tested with
codex execpolicy check, and what Cursor can and cannot do today. - Four copy-paste prompts, a red-team drill with expected results, and an ownership table a CTO can adopt.
Why do coding agents add packages that should not exist?
Section titled “Why do coding agents add packages that should not exist?”A model writes the import it has seen most often next to code like yours. When no such package exists, it can still produce a plausible name, and it produces the same name again on the next run. The OWASP GenAI Security Project’s LLM Top 10 for 2026 (LLM04 Supply Chain) describes the result: coding assistants “hallucinate plausible but nonexistent package names at scale”, which attackers register in advance. The attack is called slopsquatting.
The measurement behind it is Spracklen et al., “We Have a Package for You!”, presented at USENIX Security 2025. As summarised by Aikido Security and the Cloud Security Alliance (secondary sources; the paper itself was not read for this page), the study generated 2.23 million code samples with 16 models: 19.7% referenced at least one hallucinated package, and 43% of the hallucinated names came back on every one of 10 reruns. Repetition is what makes pre-registration pay.
Invented names are one of five dependency risks in an agent’s change, and each one slips past a human skim for a different reason:
| Risk | What the agent does | Why review misses it |
|---|---|---|
| Invented name | Imports a package that does not exist, or that an attacker registered after models kept inventing it | The name reads as plausible; the install succeeds once it is registered |
| Typosquat | Types reqeusts for requests, or picks the wrong one of two similar names | One transposed letter in a 1,400-line lockfile diff |
| Fresh compromise | Runs npm install minutes after a maintainer account is hijacked and a bad version ships | The package is familiar; only the version is new |
| Unpinned or off-registry | Writes "latest", a range with no lockfile, a git URL or a tarball URL | The manifest line looks harmless; the lockfile shows the host only to someone who looks |
| License drift | Pulls in a transitive dependency under a license your policy does not allow | Licenses are not in the diff at all unless a tool puts them there |
The fresh-compromise case is not hypothetical. The Nx security advisory, published on 27 August 2025, says the malicious nx releases contained code that “scans the file system, collects credentials, and posts them to GitHub”. An agent that installs the newest version of a trusted package on the wrong afternoon is the same failure at machine speed.
Which layer catches which dependency failure?
Section titled “Which layer catches which dependency failure?”Five layers, from the one that enforces to the ones that make it cheap to comply. Instructions in CLAUDE.md or AGENTS.md are not on the list: they tell the model your policy, and nothing makes it follow them.
| Layer | Where it runs | Invented name | Typosquat | Fresh compromise | Unpinned or off-registry | License drift | Binds cloud agents? |
|---|---|---|---|---|---|---|---|
| 1. Lockfile-diff CI gate | Every pull request | Yes | Yes, through the allowlist and name age | Yes, through the cooldown | Yes | Yes | Yes |
| 2. Cooldown in committed config | Every install that reads the repository’s config | Partly: a name registered last week is invisible | Partly | Yes | No | No | Yes, if the agent uses a recent package manager |
| 3. Registry proxy | Every install on your network | Partly | Block lists | Yes, with an age filter | Yes, if clients cannot reach the public registry | No | Only if the agent’s network is pinned to it |
| 4. Agent approval hook or rule | The developer’s session | Asks a person | Asks a person | No | Asks a person | No | No |
| 5. Install-script blocking | Every install | No | No | Stops the postinstall payload | No | No | Yes |
Layer 1 is the one an auditor accepts, because it runs on every change whoever or whatever wrote it. Layers 2 and 5 cost one line of config each and shrink what reaches layer 1. Layer 4 gives the fastest feedback and binds nothing outside the session.
Set up dependency verification, step by step
Section titled “Set up dependency verification, step by step”-
Create the allowlist. Add
.github/dependency-allowlist.txtwith one package name per line and a comment naming who approved it. Seed it from your current direct dependencies with the first prompt below. -
Give the allowlist and the gate an owner. Add
CODEOWNERSlines so a security or platform team must approve changes to the allowlist, the gate script and the workflow, and turn on Require review from Code Owners. Without this the agent can approve its own dependency by editing the list. Protecting the oracle covers theCODEOWNERSpitfalls. -
Turn on cooldowns and script blocking in the committed package-manager config (next section).
-
Add the lockfile-diff gate to CI and make it a required check.
-
Add the agent-side approval for each tool your team runs.
-
Point installs at a registry proxy once more than one team depends on the same rules.
-
Run the red-team drill and repeat it after every package-manager or agent upgrade.
Make every install wait: cooldowns and script blocking
Section titled “Make every install wait: cooldowns and script blocking”A cooldown refuses any version published within the last few days. pnpm’s documentation notes that “in most cases, malicious releases are discovered and removed from the registry within an hour”, so even a short window removes most of the exposure, and a name registered yesterday is invisible to the resolver. Put the setting in a committed config file, not in a developer’s home directory, so it travels with the repository to every laptop, CI runner and cloud agent.
min-release-age (in days) arrived in npm 11.10.0; min-release-age-exclude in npm 12.0.0. npm 12 also blocks dependency lifecycle scripts by default unless the root package.json allows them in allowScripts. Checked against the npm CLI source and changelog for npm 12.1.0 on 26 September 2026.
# .npmrc (committed)min-release-age=7save-exact=trueTo review and approve install scripts:
npm install-scripts ls # dependencies whose scripts are not yet covered by allowScriptsnpm install-scripts approve esbuild # writes a pinned entry for the installed versionnpm rebuild # runs the scripts you just approvedpnpm reads these settings from pnpm-workspace.yaml. Since pnpm 11, minimumReleaseAge defaults to 1440 minutes (one day); set it longer explicitly. strictDepBuilds defaults to true, so an install fails when a dependency has a build script that allowBuilds does not cover. Checked against the pnpm settings reference on 26 September 2026 (pnpm 12.6.0 current).
# pnpm-workspace.yaml (committed)minimumReleaseAge: 10080 # minutes: 7 daysminimumReleaseAgeExclude: - '@acme/*' # your own packages can update immediatelytrustPolicy: no-downgrade # fail if a release has weaker provenance than earlier onesallowBuilds: esbuild: trueexclude-newer accepts a duration in recent uv releases. We ran uv lock with the setting below on uv 0.12.19; uv 0.8.17 rejects the duration form (“could not be parsed as a valid date”), so upgrade before relying on it. uv writes the window into uv.lock.
[tool.uv]exclude-newer = "7 days"
# To take an urgent fix inside the window, exempt one package temporarily:# exclude-newer-package = { cryptography = false }A cooldown does not stop a slopsquatted name that was registered weeks ago, and it does not see a manifest that points at a git URL. That is layer 1’s job.
Gate the lockfile diff in CI
Section titled “Gate the lockfile diff in CI”The gate compares the lockfile on the base branch with the one in the pull request and checks every package version the change adds, including transitive ones. It reads JSON or TOML and queries the registry; it never runs npm install or uv sync, so no code from the pull request executes in the job.
We ran this script with Node 22 against lockfiles generated by npm 10.9 on 26 September 2026. A pull request that added express without an allowlist entry failed with one error, and so did an npm-workspaces monorepo that added it to apps/web/package.json only. A forged lockfile failed with seven: an unapproved direct dependency, a "latest" spec, a package resolved from https://evil.example, missing integrity, a GPL-3.0 license, a name the registry answers 404 for, and a version published the day before. A package first published 47 days earlier failed the 90-day new-name check. An unchanged lockfile passed.
#!/usr/bin/env node// scripts/dep-gate.mjs BASE_LOCK HEAD_LOCK: gate every package a pull request adds to package-lock.json// Node 22, no dependencies. Exit 1 on any ::error:: line.import { existsSync, readFileSync } from 'node:fs';
const [baseLockPath, headLockPath] = process.argv.slice(2);const REGISTRY = process.env.DEP_REGISTRY ?? 'https://registry.npmjs.org/';const COOLDOWN_DAYS = Number(process.env.DEP_COOLDOWN_DAYS ?? 7); // every new versionconst NEW_NAME_DAYS = Number(process.env.DEP_NEW_NAME_DAYS ?? 90); // a name first seen in this treeconst LICENSES = new Set((process.env.DEP_LICENSES ?? 'MIT,ISC,Apache-2.0,BSD-2-Clause,BSD-3-Clause,0BSD').split(','));const allowlist = new Set( readFileSync('.github/dependency-allowlist.txt', 'utf8').split('\n') .map((line) => line.replace(/#.*/, '').trim()).filter(Boolean),);
let errors = 0;const fail = (msg) => { console.log(`::error::${msg}`); errors++; };const days = (iso) => (Date.now() - Date.parse(iso)) / 86_400_000;
const load = (p) => (p && existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : { packages: {} });const entries = (lock) => { const out = new Map(); // "name@version" -> lockfile entry for (const [path, e] of Object.entries(lock.packages ?? {})) { if (!path.includes('node_modules/') || e.link) continue; const name = e.name ?? path.slice(path.lastIndexOf('node_modules/') + 13); out.set(`${name}@${e.version}`, { name, ...e }); } return out;};const base = load(baseLockPath);const head = load(headLockPath);const baseEntries = entries(base);const baseNames = new Set([...baseEntries.values()].map((e) => e.name));
// 1. Direct dependencies of the root ('') and of every workspace (any path outside node_modules/):// a new name needs an allowlist entry, and the spec must be a registry version.const direct = (lock) => Object.entries(lock.packages ?? {}) .filter(([path]) => !path.includes('node_modules/')) .flatMap(([path, e]) => Object.entries({ ...e.dependencies, ...e.devDependencies, ...e.optionalDependencies }) .map(([name, spec]) => ({ where: `${path || '.'}/package.json`, name, spec })));const workspaces = new Set(Object.entries(head.packages ?? {}) // sibling workspaces are linked, not downloaded .filter(([, e]) => e.link).map(([path]) => path.slice(path.lastIndexOf('node_modules/') + 13)));const baseDirect = new Set(direct(base).map((d) => d.name));for (const { where, name, spec } of direct(head)) { if (workspaces.has(name)) continue; if (!baseDirect.has(name) && !allowlist.has(name)) { fail(`${name}: new direct dependency is not in .github/dependency-allowlist.txt (${where})`); } if (!/^[~^]?\d+\.\d+\.\d+(-[\w.]+)?$/.test(spec)) { fail(`${name}: spec "${spec}" in ${where} is not a pinned registry version (no *, latest, ranges, git or URLs)`); }}
// 2. Every package@version the lockfile gained, direct or transitive.const added = [...entries(head).entries()].filter(([key]) => !baseEntries.has(key)).map(([, e]) => e);console.log(`${added.length} package versions added or changed in the lockfile`);
await Promise.all(added.map(async (e) => { const id = `${e.name}@${e.version}`; if (!e.resolved?.startsWith(REGISTRY)) fail(`${id}: resolved from ${e.resolved ?? 'nowhere'}, not ${REGISTRY}`); if (!e.integrity?.startsWith('sha512-')) fail(`${id}: missing sha512 integrity`); if (!LICENSES.has(e.license)) fail(`${id}: license "${e.license ?? 'none declared'}" is not on the allowed list`);
const res = await fetch(REGISTRY + e.name.replace('/', '%2f')); if (res.status === 404) return fail(`${id}: does not exist on the registry (hallucinated or removed name)`); if (!res.ok) return fail(`${id}: registry answered ${res.status}`); const meta = await res.json(); const published = meta.time?.[e.version]; if (!published) return fail(`${id}: version not found in registry metadata`); if (days(published) < COOLDOWN_DAYS) fail(`${id}: published ${published}, inside the ${COOLDOWN_DAYS}-day cooldown`); if (!baseNames.has(e.name) && !allowlist.has(e.name) && days(meta.time.created) < NEW_NAME_DAYS) { fail(`${id}: package first published ${meta.time.created}, less than ${NEW_NAME_DAYS} days ago`); }}));
console.log(errors ? `${errors} dependency problem(s)` : 'Dependency gate passed');process.exit(errors ? 1 : 0);Adjust before you adopt it. Direct dependencies come from the root and every workspace, so a package added to one app needs an allowlist entry too. The spec rule accepts exact, caret or tilde versions because the lockfile pins the result; drop it for a published library. The license list is an allowlist, so an undeclared license fails until a person exempts the package. The 7-day and 90-day windows are starting points, and allowlisted names skip the second. Set DEP_REGISTRY to your proxy’s URL; the script sends no credentials, so give CI anonymous read access or add an Authorization header.
uv.lock records the upload time of every file, so the cooldown check works offline; the new-name check asks a PyPI-style JSON API, public PyPI unless DEP_PYPI_JSON names your proxy’s. We ran it with Python 3.11 against lockfiles from uv 0.8.17: an unapproved direct dependency, a name PyPI answers 404 for, a file uploaded the day before, and a package from a URL source each failed; an allowlisted addition passed. uv.lock does not record licenses, so dependency review or OSV-Scanner (see the notes below the workflow) checks them.
#!/usr/bin/env python3"""scripts/dep_gate.py BASE_LOCK HEAD_LOCK: gate every package a pull request adds to uv.lock (Python 3.11+)."""import json, os, sys, tomllib, urllib.error, urllib.requestfrom datetime import datetime, timezonefrom pathlib import Path
INDEX = os.environ.get("DEP_INDEX", "https://pypi.org/simple")PYPI_JSON = os.environ.get("DEP_PYPI_JSON", "https://pypi.org/pypi") # your proxy's JSON API, if it has oneCOOLDOWN_DAYS = int(os.environ.get("DEP_COOLDOWN_DAYS", "7"))NEW_NAME_DAYS = int(os.environ.get("DEP_NEW_NAME_DAYS", "90"))allowlist = {l.split("#")[0].strip() for l in Path(".github/dependency-allowlist.txt").read_text().splitlines()} - {""}errors = 0
def fail(msg): global errors print(f"::error::{msg}"); errors += 1
def age(iso): return (datetime.now(timezone.utc) - datetime.fromisoformat(iso.replace("Z", "+00:00"))).days
def load(path): p = Path(path) return tomllib.loads(p.read_text()).get("package", []) if p.exists() else []
base = load(sys.argv[1])base_ids = {(p["name"], p["version"]) for p in base}base_names = {p["name"] for p in base}head = load(sys.argv[2])
def direct(pkgs): # the project's own entry lists its direct dependencies return {d["name"] for p in pkgs if "virtual" in p.get("source", {}) or "editable" in p.get("source", {}) for d in p.get("dependencies", []) + [d for g in p.get("dev-dependencies", {}).values() for d in g]}
for name in sorted(direct(head) - direct(base) - allowlist): fail(f"{name}: new direct dependency is not in .github/dependency-allowlist.txt")for p in head: src = p.get("source", {}) if "virtual" in src or "editable" in src or (p["name"], p["version"]) in base_ids: continue # the project itself, or unchanged pid = f'{p["name"]}=={p["version"]}' if src.get("registry") != INDEX: fail(f"{pid}: source {src} is not {INDEX}"); continue files = [p["sdist"]] if "sdist" in p else [] files += p.get("wheels", []) if not files or any("upload-time" not in f for f in files): fail(f"{pid}: no upload-time in uv.lock, so its age cannot be checked"); continue if min(age(f["upload-time"]) for f in files) < COOLDOWN_DAYS: fail(f"{pid}: uploaded inside the {COOLDOWN_DAYS}-day cooldown") if p["name"] in base_names or p["name"] in allowlist: continue try: with urllib.request.urlopen(f'{PYPI_JSON}/{p["name"]}/json') as r: releases = json.load(r)["releases"] except urllib.error.HTTPError as e: fail(f"{pid}: {PYPI_JSON} answered {e.code} (hallucinated or removed name?)"); continue first = min(f["upload_time_iso_8601"] for fs in releases.values() for f in fs) if age(first) < NEW_NAME_DAYS: fail(f"{pid}: first release {first}, less than {NEW_NAME_DAYS} days ago; needs an allowlist entry")
print(f"{errors} dependency problem(s)" if errors else "Dependency gate passed")sys.exit(1 if errors else 0)Set DEP_INDEX to your proxy’s simple index and DEP_PYPI_JSON to its JSON API base, or the new-name check fails in a CI job that cannot reach pypi.org.
The workflow
Section titled “The workflow”The gate is itself part of the oracle: an agent that can edit scripts/dep-gate.mjs in the same pull request can turn it off. So the job runs the base branch’s copy of each script, while the allowlist comes from the pull request, where CODEOWNERS forces a named approval.
name: dependency-gateon: pull_requestpermissions: contents: readjobs: lockfile-diff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: { fetch-depth: 0, persist-credentials: false } - uses: actions/setup-node@v7 with: { node-version: 22 } - name: Gate new npm packages (script from the base branch) env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | git show "$BASE_SHA:scripts/dep-gate.mjs" > "$RUNNER_TEMP/dep-gate.mjs" git show "$BASE_SHA:package-lock.json" > "$RUNNER_TEMP/base-lock.json" || echo '{}' > "$RUNNER_TEMP/base-lock.json" node "$RUNNER_TEMP/dep-gate.mjs" "$RUNNER_TEMP/base-lock.json" package-lock.json - name: Check lockfile hosts, integrity and package names run: npx --yes lockfile-lint@5.0.1 --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity --validate-package-names - name: Gate new Python packages (script from the base branch) if: hashFiles('uv.lock') != '' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | git show "$BASE_SHA:scripts/dep_gate.py" > "$RUNNER_TEMP/dep_gate.py" git show "$BASE_SHA:uv.lock" > "$RUNNER_TEMP/base-uv.lock" || true python3 "$RUNNER_TEMP/dep_gate.py" "$RUNNER_TEMP/base-uv.lock" uv.lock dependency-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: { persist-credentials: false } - uses: actions/dependency-review-action@v5 with: fail-on-severity: moderate allow-licenses: MIT, ISC, Apache-2.0, BSD-2-Clause, BSD-3-Clause, 0BSD warn-on-openssf-scorecard-level: 3Notes on the jobs:
lockfile-lintcatches what a hand-edited lockfile can hide: aresolvedURL on another host or over plain HTTP, a weak integrity hash, and a tarball URL naming a different package than the entry. Version 5.0.1 passed our clean lockfile and reported the forged one’sevil.examplehost. Swapnpmin--allowed-hostsfor your proxy’s host name.actions/dependency-review-action@v5fails on known vulnerabilities and licenses outsideallow-licenses, and prints OpenSSF Scorecard results. It needs a public repository or GitHub Advanced Security, and runner v2.327.1 or later. Otherwise run OSV-Scanner:osv-scanner --licenses="MIT,ISC,Apache-2.0,BSD-2-Clause,BSD-3-Clause,0BSD" .checks licenses and vulnerabilities for npm and Python.- Merge the scripts first.
git showfails until the base branch contains them, so land the scripts, the allowlist andCODEOWNERSin a first pull request. Delete the steps for an ecosystem you do not use. - No install step, so
contents: readis enough. Your normal test job’snpm cioruv sync --lockedfails when the lockfile does not match the manifest. - Make both jobs required in the branch ruleset. For a pull request that edits
.github/workflows/, see running the decisive check where the pull request cannot edit it.
Pin every install to a registry proxy
Section titled “Pin every install to a registry proxy”A registry proxy applies one policy to every client at once: laptops, CI, and any cloud agent whose network you control. It also gives you a kill switch: when a compromised version is announced, you block it in one place instead of in every repository.
Verdaccio (npm, version 6.10.4 on 26 September 2026) ships the @verdaccio/package-filter plugin, which can hide versions younger than a number of days and block names, scopes or version ranges:
# verdaccio config.yaml (excerpt)uplinks: npmjs: url: https://registry.npmjs.org/packages: '**': access: $authenticated proxy: npmjsfilters: '@verdaccio/package-filter': minAgeDays: 7 block: - package: 'nx' # the releases named in the August 2025 advisory versions: '20.9.0 || 20.10.0 || 20.11.0 || 20.12.0 || 21.5.0 || 21.6.0 || 21.7.0 || 21.8.0'The plugin filters manifests only; tarballs the proxy already cached stay cached, so purge them when you block a version. Artifactory and Nexus have equivalents.
Then point clients at the proxy in committed files, and make the public registry unreachable from agent sandboxes so a --registry flag cannot route around it:
# .npmrc (committed)registry=https://npm.internal.example.com/# pyproject.toml: replace PyPI as uv's default index[[tool.uv.index]]name = "internal"url = "https://pypi.internal.example.com/simple"default = trueSocket Firewall Free (npm i -g sfw) is a lighter option for one developer: sfw npm install or sfw uv pip install flask runs the package manager through a local proxy that blocks packages Socket flags as malicious. It supports npm, yarn, pnpm, pip, uv and cargo. It protects the machine it runs on; it does not replace the CI gate.
Make the agent ask before it adds a dependency
Section titled “Make the agent ask before it adds a dependency”The agent-side control is a speed bump, not enforcement: an agent can write a manifest through a script, and a cloud agent does not run your laptop’s settings. Its value is that a person sees the package name before anything downloads.
A PreToolUse hook returns "ask" for install commands that name a package and for edits to dependency manifests. A hook’s "ask" forces a prompt even in auto mode: the classifier can deny the call but cannot approve it silently (Claude Code hooks documentation, checked 26 September 2026 against Claude Code 2.1.283, the latest channel). permissionDecisionReason goes to the person for "ask" and to Claude for "deny".
{ "hooks": { "PreToolUse": [ { "matcher": "Bash|Edit|Write", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/dep-guard.sh" } ] } ] }}#!/usr/bin/env bash# .claude/hooks/dep-guard.sh: a person approves every dependency change (PreToolUse on Bash|Edit|Write)command -v jq >/dev/null || { echo "dep-guard: jq missing" >&2; exit 2; }input=$(cat)tool=$(jq -r '.tool_name' <<<"$input")mode=${DEP_GUARD_MODE:-ask} # set DEP_GUARD_MODE=deny for claude -p and CI runsdecide() { jq -n --arg d "$mode" --arg r "$1" \ '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: $d, permissionDecisionReason: $r}}' exit 0}if [ "$tool" = "Bash" ]; then cmd=$(jq -r '.tool_input.command // empty' <<<"$input") # An install that names a package; a bare `npm install` or `npm ci` from the lockfile passes. if grep -qE '(^|[;&|(]|&&)[[:space:]]*(sfw[[:space:]]+)?((npm|pnpm|yarn|bun)[[:space:]]+(install|i|add)|pip3?[[:space:]]+install|uv[[:space:]]+(add|pip[[:space:]]+install)|poetry[[:space:]]+add|cargo[[:space:]]+add|go[[:space:]]+get)([[:space:]]+-[^[:space:]]+)*[[:space:]]+[^-[:space:]]' <<<"$cmd"; then decide "New dependency: check the name against .github/dependency-allowlist.txt and the registry before approving. Command: $cmd" fielse file=$(jq -r '.tool_input.file_path // empty' <<<"$input") case "$(basename "$file")" in package.json|package-lock.json|pnpm-lock.yaml|yarn.lock|pyproject.toml|uv.lock|requirements*.txt|go.mod|Cargo.toml|.npmrc|pnpm-workspace.yaml) decide "Dependency manifest edit: $file. Approve only if the change matches an allowlist entry." ;; esacfiexit 0We fed the script 13 tool calls. It asked for npm install left-pad, cd web && npm i -D reqeusts, uv add httpx, uv pip install -r requirements.txt, pip install requests, sfw npm install zod and an Edit of apps/web/package.json, and stayed silent for npm install, npm install --ignore-scripts, npm ci && npm test, uv sync --frozen and a Write to src/index.ts. With DEP_GUARD_MODE=deny it denied pnpm add zod, which suits claude -p runs where nobody can answer a prompt. Without jq on PATH the guard blocks every call rather than none.
Managed settings close the gaps the hook leaves: sandbox.enabled: true (with sandbox.failIfUnavailable: true, so a machine without the sandbox refuses to start instead of running unsandboxed), sandbox.network.allowedDomains and sandbox.network.allowManagedDomainsOnly: true limit sandboxed commands to your proxy’s host, so a script that calls the public registry directly is refused; the lock applies to sandboxed commands only, so also set sandbox.allowUnsandboxedCommands: false. Deploy the hook itself through managed settings if developers must not be able to remove it.
A Codex rules file with decision = "prompt" makes Codex ask before running a matching command. We checked this file with codex execpolicy check on Codex CLI 0.157.1 on 26 September 2026: npm install left-pad, npm i, pnpm add zod, uv add requests, uv pip install x and pip install requests each returned "decision":"prompt" with the justification; npm ci, npm test, uv pip list and uv sync --frozen matched no rule. A prefix rule cannot tell a bare npm install from one that names a package, so Codex also prompts for a plain reinstall from the lockfile, which the Claude Code hook lets through.
prefix_rule( pattern = ["npm", ["install", "i", "add"]], decision = "prompt", justification = "New dependencies need an entry in .github/dependency-allowlist.txt",)prefix_rule(pattern = ["pnpm", "add"], decision = "prompt", justification = "New dependencies need approval")prefix_rule(pattern = ["uv", "add"], decision = "prompt", justification = "New dependencies need approval")prefix_rule(pattern = ["uv", "pip", "install"], decision = "prompt", justification = "New dependencies need approval")prefix_rule(pattern = ["pip", "install"], decision = "prompt", justification = "New dependencies need approval")Test your own copy the same way before you commit it:
# Terminal, Codex CLI 0.157.1codex execpolicy check --rules .codex/rules/dependencies.rules npm install left-padA prefix rule matches the command, not the file, so an apply_patch edit to package.json is not covered; the CI gate and CODEOWNERS on manifests are. To make the rules binding for a team, an administrator puts them in requirements.toml, which accepts only prompt or forbidden decisions; the EU software regulation page shows that form. Codex also has PreToolUse hooks (12 hook events in 0.157.1); port the Claude Code script’s logic to Codex’s hook schema if you want the manifest-edit check in the session too.
Cursor has hooks, which “run before or after defined stages of the agent loop and can observe, block, or modify behavior” (cursor.com/docs/hooks, checked 28 August 2026), so the Claude Code script’s patterns can be ported. We could not re-check Cursor’s hook event names or configuration file on 26 September 2026 (cursor.com was unreachable from our environment); take them from Cursor’s hooks reference.
Cursor’s Cloud Agents “run in isolated VMs in the cloud with full development environments” (checked 28 August 2026), so nothing on your laptop applies to them. For Cursor, the control is what travels with the repository: the committed cooldown and registry settings, and the CI gate.
How do you check a dependency the agent proposes?
Section titled “How do you check a dependency the agent proposes?”When the gate blocks an addition, someone has to decide whether the package belongs on the allowlist. The decision takes two minutes when the evidence is gathered for you, and the agent can gather it without installing anything.
Replace <PACKAGE> with the name from the gate’s error line.
How do you know the dependency gate works?
Section titled “How do you know the dependency gate works?”Prove it with a red-team pull request after you set it up and after every package-manager, runner or agent upgrade. Open a branch that makes each change below, one commit each, and compare with the expected result.
| Change in the drill branch | Expected result |
|---|---|
| Add a real, allowlisted package | Gate passes |
| Add a real package that is not on the allowlist | new direct dependency is not in .github/dependency-allowlist.txt |
Add a direct dependency with the spec "latest" | is not a pinned registry version |
Hand-edit a lockfile entry so resolved points at another host | Gate: resolved from …; lockfile-lint: detected invalid host(s) |
| Add an entry for a name that does not exist | does not exist on the registry |
| Add a package first published less than 90 days ago | less than 90 days ago |
| Add a version published yesterday | inside the 7-day cooldown |
| Add a GPL-3.0 package (or your policy’s excluded license) | is not on the allowed list, and dependency review fails |
Edit scripts/dep-gate.mjs to process.exit(0) in the same branch | Nothing changes: the job runs the base branch’s copy, and CODEOWNERS requests the platform team |
Ask the agent to npm install a new package | Claude Code: a permission prompt naming the command; Codex: a prompt with the rule’s justification |
Ownership, so the drill is somebody’s job:
| Role | Owns | Evidence it leaves |
|---|---|---|
| Developer | The instruction block, the hook or rules file in the repository, the vetting prompt when blocked | The pull request conversation |
| Tech lead | The workflow as a required check, the drill after upgrades, exceptions to the cooldown | Drill branch results; each exception’s pull request |
| Security or platform team (code owners) | The allowlist, the license list, the proxy’s block list | Allowlist history with approver and date |
| CTO | The policy: which layers are mandatory, the windows, which licenses are acceptable | The policy document and the list of repositories with the gate required |
Track two numbers per month from the gate’s logs: how many agent pull requests the gate blocked, and the median time from block to decision. A rising block count means agents are reaching for packages more often, and your instructions or allowlist need work. A long decision time means people will start pushing for bypasses.
For teams selling into the EU, the allowlist history and the gate log are also the Cyber Resilience Act’s due-diligence evidence for third-party components. CRA, NIS2 and DORA-EU for teams using agents maps each check to its provision and adds the SBOM step for releases.
What breaks when you gate agent dependencies?
Section titled “What breaks when you gate agent dependencies?”An urgent security fix is inside the cooldown. npm keeps the vulnerable version and exits non-zero; pnpm and uv refuse to resolve it. Recovery: exempt that one package (min-release-age-exclude, minimumReleaseAgeExclude with the exact version, or exclude-newer-package) in the pull request that takes the fix, and remove the exemption when the version ages past the window.
The agent deleted the lockfile and reinstalled. The gate reports hundreds of changed versions, many inside the cooldown. Recovery: restore the base branch’s lockfile, re-apply only the intended change with npm install --package-lock-only or uv lock, and keep the “never regenerate a lockfile” line in the agent’s instructions.
The agent routes around the hook. It writes package.json with a Node or Python one-liner, or runs npx some-tool or uvx some-tool, which download and run a package in one step. The hook sees neither. Recovery: nothing to recover in the pull request, because the CI gate still checks the lockfile; on the machine, the sandbox network allowlist is what stops the download.
The allowlist becomes a rubber stamp. Every blocked package is added without reading the evidence. Recovery: require the vetting prompt’s table in the allowlist pull request, and keep the code owners to a team that says no sometimes.
The gate fails with a registry error, not a policy error. A 401 or 403 means the job cannot read your proxy; a 429 means rate limiting on a very large lockfile change. Recovery: give CI read access to the proxy and re-run. The gate fails closed, which is what you want.