Skip to content

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:

RiskWhat the agent doesWhy review misses it
Invented nameImports a package that does not exist, or that an attacker registered after models kept inventing itThe name reads as plausible; the install succeeds once it is registered
TyposquatTypes reqeusts for requests, or picks the wrong one of two similar namesOne transposed letter in a 1,400-line lockfile diff
Fresh compromiseRuns npm install minutes after a maintainer account is hijacked and a bad version shipsThe package is familiar; only the version is new
Unpinned or off-registryWrites "latest", a range with no lockfile, a git URL or a tarball URLThe manifest line looks harmless; the lockfile shows the host only to someone who looks
License driftPulls in a transitive dependency under a license your policy does not allowLicenses 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.

LayerWhere it runsInvented nameTyposquatFresh compromiseUnpinned or off-registryLicense driftBinds cloud agents?
1. Lockfile-diff CI gateEvery pull requestYesYes, through the allowlist and name ageYes, through the cooldownYesYesYes
2. Cooldown in committed configEvery install that reads the repository’s configPartly: a name registered last week is invisiblePartlyYesNoNoYes, if the agent uses a recent package manager
3. Registry proxyEvery install on your networkPartlyBlock listsYes, with an age filterYes, if clients cannot reach the public registryNoOnly if the agent’s network is pinned to it
4. Agent approval hook or ruleThe developer’s sessionAsks a personAsks a personNoAsks a personNoNo
5. Install-script blockingEvery installNoNoStops the postinstall payloadNoNoYes

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”
  1. Create the allowlist. Add .github/dependency-allowlist.txt with one package name per line and a comment naming who approved it. Seed it from your current direct dependencies with the first prompt below.

  2. Give the allowlist and the gate an owner. Add CODEOWNERS lines 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 the CODEOWNERS pitfalls.

  3. Turn on cooldowns and script blocking in the committed package-manager config (next section).

  4. Add the lockfile-diff gate to CI and make it a required check.

  5. Add the agent-side approval for each tool your team runs.

  6. Point installs at a registry proxy once more than one team depends on the same rules.

  7. 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=7
save-exact=true

To review and approve install scripts:

Terminal window
npm install-scripts ls # dependencies whose scripts are not yet covered by allowScripts
npm install-scripts approve esbuild # writes a pinned entry for the installed version
npm rebuild # runs the scripts you just approved

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.

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 version
const NEW_NAME_DAYS = Number(process.env.DEP_NEW_NAME_DAYS ?? 90); // a name first seen in this tree
const 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.

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.

.github/workflows/dependency-gate.yml
name: dependency-gate
on: pull_request
permissions:
contents: read
jobs:
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: 3

Notes on the jobs:

  • lockfile-lint catches what a hand-edited lockfile can hide: a resolved URL 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’s evil.example host. Swap npm in --allowed-hosts for your proxy’s host name.
  • actions/dependency-review-action@v5 fails on known vulnerabilities and licenses outside allow-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 show fails until the base branch contains them, so land the scripts, the allowlist and CODEOWNERS in a first pull request. Delete the steps for an ecosystem you do not use.
  • No install step, so contents: read is enough. Your normal test job’s npm ci or uv sync --locked fails 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.

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: npmjs
filters:
'@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 = true

Socket 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 runs
decide() {
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"
fi
else
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." ;;
esac
fi
exit 0

We 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.

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 branchExpected result
Add a real, allowlisted packageGate passes
Add a real package that is not on the allowlistnew 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 hostGate: resolved from …; lockfile-lint: detected invalid host(s)
Add an entry for a name that does not existdoes not exist on the registry
Add a package first published less than 90 days agoless than 90 days ago
Add a version published yesterdayinside 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 branchNothing changes: the job runs the base branch’s copy, and CODEOWNERS requests the platform team
Ask the agent to npm install a new packageClaude Code: a permission prompt naming the command; Codex: a prompt with the rule’s justification

Ownership, so the drill is somebody’s job:

RoleOwnsEvidence it leaves
DeveloperThe instruction block, the hook or rules file in the repository, the vetting prompt when blockedThe pull request conversation
Tech leadThe workflow as a required check, the drill after upgrades, exceptions to the cooldownDrill branch results; each exception’s pull request
Security or platform team (code owners)The allowlist, the license list, the proxy’s block listAllowlist history with approver and date
CTOThe policy: which layers are mandatory, the windows, which licenses are acceptableThe 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.