Przejdź do głównej zawartości

Zmyślone pakiety i slopsquatting: kontrola zależności w zmianach agentów

Weryfikacja zależności w zmianach agentów to automatyczne kontrole, które nie pozwalają agentowi kodującemu dodać pakietu, który nie istnieje, pakietu zarejestrowanego przez atakującego pod zmyśloną lub przekręconą nazwą, wersji opublikowanej kilka godzin temu ani licencji spoza polityki. Egzekwuje je bramka CI na diffie lockfile’a, wspierana przez cooldown, proxy rejestru i hook zatwierdzający.

Twój agent naprawił w nocy błąd stref czasowych. W podsumowaniu pisze, że „dodał date-fns-tz-utils do obsługi przesunięć”, pull request jest zielony, a diff lockfile’a ma 1400 linii, których nikt nie przeczyta. 26 września 2026 roku tej nazwy nie było w npm. Jeśli modele będą ją dalej podpowiadać, ktoś ją zarejestruje, a kolejny agent, który ją podpowie, zainstaluje to, co tam wgrano. Ta strona jest dla programisty, który konfiguruje agenta, dla tech leada, który odpowiada za pipeline, i dla CTO, który musi pokazać audytorowi, jak nowy kod stron trzecich trafia do produktu.

  • Przetestowaną bramkę CI dla npm (package-lock.json) i Pythona (uv.lock), która odrzuca pakiety nieistniejące, zbyt świeże, niezatwierdzone, nieprzypięte, spoza rejestru i ze złą licencją, bezpośrednie i przechodnie.
  • Ustawienia cooldownu dla npm, pnpm i uv, które trzymają wersje sprzed kilku dni z dala od każdej instalacji korzystającej z repozytorium.
  • Konfigurację proxy rejestru, która ukrywa młode wersje i zablokowane nazwy w całej organizacji.
  • Zatwierdzanie po stronie agenta: hook Claude Code przetestowany na 13 wywołaniach, plik reguł Codex sprawdzony przez codex execpolicy check i opis tego, co dziś da się zrobić w Cursorze, a czego nie.
  • Cztery prompty do skopiowania, ćwiczenie red-team z oczekiwanymi wynikami i tabelę odpowiedzialności, którą CTO może przyjąć bez zmian.

Dlaczego agenci dodają pakiety, które nie powinny istnieć?

Dział zatytułowany „Dlaczego agenci dodają pakiety, które nie powinny istnieć?”

Model pisze import, który najczęściej widział obok kodu podobnego do twojego. Gdy taki pakiet nie istnieje, model i tak potrafi wymyślić wiarygodną nazwę, a przy następnym uruchomieniu wymyśla tę samą. OWASP GenAI Security Project w LLM Top 10 na 2026 rok (LLM04 Supply Chain) opisuje skutek: asystenci kodowania „hallucinate plausible but nonexistent package names at scale”, a atakujący rejestrują te nazwy z wyprzedzeniem. Ten atak nazywa się slopsquatting.

Pomiar, na który powołuje się OWASP, to praca Spracklena i współautorów „We Have a Package for You!” z USENIX Security 2025. Według streszczeń Aikido Security i Cloud Security Alliance (źródła wtórne; samej pracy nie czytaliśmy na potrzeby tej strony) badanie objęło 2,23 mln próbek kodu z 16 modeli: 19,7% odwoływało się do co najmniej jednego zmyślonego pakietu, a 43% zmyślonych nazw wracało w każdym z 10 powtórzeń. To powtarzalność sprawia, że rejestrowanie nazw z wyprzedzeniem się opłaca.

Zmyślona nazwa to jedno z pięciu ryzyk zależności w zmianie agenta, a każde z nich umyka pobieżnemu przeglądowi z innego powodu:

RyzykoCo robi agentDlaczego review tego nie widzi
Zmyślona nazwaImportuje pakiet, który nie istnieje albo który atakujący zarejestrował, bo modele ciągle go wymyślałyNazwa brzmi wiarygodnie; po rejestracji instalacja przechodzi
TyposquatPisze reqeusts zamiast requests albo wybiera nie tę z dwóch podobnych nazwJedna przestawiona litera w diffie lockfile’a na 1400 linii
Świeże przejęcieUruchamia npm install kilka minut po przejęciu konta opiekuna i publikacji złej wersjiPakiet jest znany; nowa jest tylko wersja
Brak przypięcia albo źródło spoza rejestruWpisuje "latest", zakres bez lockfile’a, URL gita albo URL tarballaLinia w manifeście wygląda niewinnie; host widać w lockfile’u tylko temu, kto tam zajrzy
Dryf licencjiWciąga zależność przechodnią na licencji, której polityka nie dopuszczaLicencji w ogóle nie ma w diffie, dopóki jakieś narzędzie ich tam nie pokaże

Świeże przejęcie to nie teoria. Komunikat bezpieczeństwa Nx z 27 sierpnia 2025 roku mówi, że złośliwe wydania nx zawierały kod, który „scans the file system, collects credentials, and posts them to GitHub”. Agent, który złego popołudnia instaluje najnowszą wersję zaufanego pakietu, popełnia ten sam błąd w tempie maszyny.

Pięć warstw: od tej, która egzekwuje, po te, dzięki którym przestrzeganie zasad jest tanie. Instrukcji w CLAUDE.md czy AGENTS.md nie ma na liście: mówią modelowi o polityce, ale nic nie zmusza go do jej przestrzegania.

WarstwaGdzie działaZmyślona nazwaTyposquatŚwieże przejęcieBrak przypięcia lub obce źródłoDryf licencjiObejmuje agentów w chmurze?
1. Bramka CI na diffie lockfile’aKażdy pull requestTakTak, przez allowlistę i wiek nazwyTak, przez cooldownTakTakTak
2. Cooldown w commitowanej konfiguracjiKażda instalacja, która czyta konfigurację repozytoriumCzęściowo: nazwa zarejestrowana w zeszłym tygodniu jest niewidocznaCzęściowoTakNieNieTak, jeśli agent używa aktualnego menedżera pakietów
3. Proxy rejestruKażda instalacja w twojej sieciCzęściowoListy blokadTak, z filtrem wiekuTak, jeśli klienci nie mają dostępu do publicznego rejestruNieTylko jeśli sieć agenta jest przypięta do proxy
4. Hook lub reguła zatwierdzająca w agencieSesja programistyPyta człowiekaPyta człowiekaNiePyta człowiekaNieNie
5. Blokowanie skryptów instalacyjnychKażda instalacjaNieNieZatrzymuje ładunek w postinstallNieNieTak

Warstwę 1 zaakceptuje audytor, bo działa na każdej zmianie, niezależnie od tego, kto albo co ją napisał. Warstwy 2 i 5 kosztują po jednej linii konfiguracji i zmniejszają to, co w ogóle dociera do warstwy 1. Warstwa 4 daje najszybszą informację zwrotną i nie wiąże niczego poza sesją.

  1. Utwórz allowlistę. Dodaj .github/dependency-allowlist.txt: jedna nazwa pakietu w linii i komentarz, kto ją zatwierdził. Wypełnij ją obecnymi zależnościami bezpośrednimi za pomocą pierwszego promptu poniżej.

  2. Daj allowliście i bramce właściciela. Dodaj linie w CODEOWNERS, tak żeby zmiany allowlisty, skryptu bramki i workflowu musiał zatwierdzić zespół bezpieczeństwa albo platformy, i włącz Require review from Code Owners. Bez tego agent może sam zatwierdzić swoją zależność, edytując listę. Pułapki CODEOWNERS opisuje strona o ochronie wyroczni.

  3. Włącz cooldown i blokowanie skryptów w commitowanej konfiguracji menedżera pakietów (następna sekcja).

  4. Dodaj bramkę na diffie lockfile’a do CI i ustaw ją jako wymagany check.

  5. Dodaj zatwierdzanie po stronie agenta w każdym narzędziu, którego używa zespół.

  6. Skieruj instalacje na proxy rejestru, gdy z tych samych zasad korzysta więcej niż jeden zespół.

  7. Przeprowadź ćwiczenie red-team i powtarzaj je po każdej aktualizacji menedżera pakietów lub agenta.

Niech każda instalacja poczeka: cooldown i blokowanie skryptów

Dział zatytułowany „Niech każda instalacja poczeka: cooldown i blokowanie skryptów”

Cooldown odrzuca każdą wersję opublikowaną w ciągu ostatnich kilku dni. Dokumentacja pnpm zauważa, że „in most cases, malicious releases are discovered and removed from the registry within an hour”, więc nawet okno liczone w dniach obejmuje czas, w którym większość z nich zostaje wykryta, a nazwa zarejestrowana wczoraj jest dla resolvera niewidoczna. Umieść ustawienie w commitowanym pliku konfiguracyjnym, a nie w katalogu domowym programisty, żeby trafiało razem z repozytorium na każdy laptop, runner CI i do każdego agenta w chmurze.

min-release-age (w dniach) pojawił się w npm 11.10.0, a min-release-age-exclude w npm 12.0.0. npm 12 domyślnie blokuje też skrypty cyklu życia zależności, chyba że główny package.json dopuszcza je w allowScripts. Sprawdzone w kodzie źródłowym i changelogu npm CLI dla npm 12.1.0, 26 września 2026 roku.

# .npmrc (committed)
min-release-age=7
save-exact=true

Przegląd i zatwierdzanie skryptów instalacyjnych:

Okno terminala
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

Cooldown nie zatrzyma nazwy slopsquattingowej zarejestrowanej kilka tygodni temu i nie widzi manifestu, który wskazuje na URL gita. To zadanie warstwy 1.

Bramka porównuje lockfile z gałęzi bazowej z lockfile’em z pull requesta i sprawdza każdą wersję pakietu, którą zmiana dodaje, także przechodnią. Czyta JSON albo TOML i odpytuje rejestr; nigdy nie uruchamia npm install ani uv sync, więc w tym jobie nie wykonuje się żaden kod z pull requesta.

Uruchomiliśmy ten skrypt na Node 22 na lockfile’ach wygenerowanych przez npm 10.9, 26 września 2026 roku. Pull request, który dodał express bez wpisu na allowliście, dostał jeden błąd, tak samo jak monorepo z workspace’ami npm, w którym express trafił tylko do apps/web/package.json. Podrobiony lockfile dostał siedem: niezatwierdzona zależność bezpośrednia, specyfikacja "latest", pakiet pobierany z https://evil.example, brak integrity, licencja GPL-3.0, nazwa, dla której rejestr zwraca 404, i wersja opublikowana dzień wcześniej. Pakiet opublikowany po raz pierwszy 47 dni wcześniej nie przeszedł 90-dniowej kontroli nowej nazwy. Niezmieniony lockfile przeszedł.

#!/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);

Dostosuj go przed wdrożeniem. Zależności bezpośrednie pochodzą z wpisu głównego i z każdego workspace’u, więc pakiet dodany do jednej aplikacji też potrzebuje wpisu na allowliście. Reguła specyfikacji przyjmuje dokładną wersję albo zakres z daszkiem lub tyldą, bo lockfile i tak przypina wynik; w publikowanej bibliotece ją usuń. Lista licencji jest allowlistą, więc brak zadeklarowanej licencji oblewa kontrolę, dopóki człowiek nie zwolni pakietu. Okna 7 i 90 dni to punkt wyjścia, a nazwy z allowlisty omijają drugie z nich. Ustaw DEP_REGISTRY na URL swojego proxy; skrypt nie wysyła poświadczeń, więc daj CI anonimowy odczyt albo dodaj nagłówek Authorization.

Bramka sama jest częścią wyroczni: agent, który może edytować scripts/dep-gate.mjs w tym samym pull requeście, może ją wyłączyć. Dlatego job uruchamia kopię skryptów z gałęzi bazowej, a allowlistę bierze z pull requesta, gdzie CODEOWNERS wymusza zatwierdzenie przez wskazany zespół.

.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

Uwagi do jobów:

  • lockfile-lint łapie to, co może ukryć ręcznie edytowany lockfile: adres resolved na innym hoście albo po zwykłym HTTP, słaby hash integrity i URL tarballa wskazujący inny pakiet niż wpis. Wersja 5.0.1 przepuściła nasz czysty lockfile i zgłosiła host evil.example w podrobionym. Zamień npm w --allowed-hosts na nazwę hosta twojego proxy.
  • actions/dependency-review-action@v5 oblewa znane podatności i licencje spoza allow-licenses oraz wypisuje wyniki OpenSSF Scorecard. Wymaga repozytorium publicznego albo GitHub Advanced Security oraz runnera 2.327.1 lub nowszego. W przeciwnym razie uruchom OSV-Scanner: osv-scanner --licenses="MIT,ISC,Apache-2.0,BSD-2-Clause,BSD-3-Clause,0BSD" . sprawdza licencje i podatności dla npm i Pythona.
  • Najpierw scal skrypty. git show przerywa job, dopóki gałąź bazowa ich nie zawiera, więc skrypty, allowlistę i CODEOWNERS wprowadź pierwszym pull requestem. Usuń kroki dla ekosystemu, którego nie używasz.
  • Bez kroku instalacji, więc wystarcza contents: read. npm ci albo uv sync --locked w zwykłym jobie testowym kończy się błędem, gdy lockfile nie zgadza się z manifestem.
  • Ustaw oba joby jako wymagane w rulesecie gałęzi. Pull requesty, które edytują .github/workflows/, omawia sekcja o uruchamianiu decydującego checku tam, gdzie pull request go nie zmieni.

Proxy rejestru stosuje jedną politykę do wszystkich klientów naraz: laptopów, CI i każdego agenta w chmurze, którego sieć kontrolujesz. Daje też wyłącznik awaryjny: gdy ogłoszono przejęcie wersji, blokujesz ją w jednym miejscu, a nie w każdym repozytorium.

Verdaccio (npm, wersja 6.10.4 na 26 września 2026 roku) ma wbudowaną wtyczkę @verdaccio/package-filter, która ukrywa wersje młodsze niż zadana liczba dni i blokuje nazwy, scope’y albo zakresy wersji:

# 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'

Wtyczka filtruje tylko manifesty; tarballe, które proxy już zbuforowało, zostają w cache’u, więc czyść je, gdy blokujesz wersję. Artifactory i Nexus mają odpowiedniki.

Następnie skieruj klientów na proxy w commitowanych plikach i odetnij sandboxom agentów dostęp do publicznego rejestru, żeby flaga --registry nie pozwalała go obejść:

# .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) to lżejsza opcja dla jednego programisty: sfw npm install albo sfw uv pip install flask uruchamia menedżer pakietów przez lokalne proxy, które blokuje pakiety oznaczone przez Socket jako złośliwe. Obsługuje npm, yarn, pnpm, pip, uv i cargo. Chroni maszynę, na której działa; nie zastępuje bramki CI.

Kontrola po stronie agenta to próg zwalniający, a nie egzekwowanie: agent może zapisać manifest skryptem, a agent w chmurze nie uruchamia ustawień z twojego laptopa. Jej wartość polega na tym, że człowiek widzi nazwę pakietu, zanim cokolwiek zostanie pobrane.

Hook PreToolUse zwraca "ask" dla poleceń instalacji, które podają nazwę pakietu, i dla edycji manifestów zależności. "ask" z hooka wymusza pytanie nawet w trybie auto: klasyfikator może odrzucić wywołanie, ale nie może go po cichu zatwierdzić (dokumentacja hooków Claude Code, sprawdzona 26 września 2026 roku dla Claude Code 2.1.283, kanał latest). permissionDecisionReason trafia do człowieka przy "ask" i do Claude’a przy "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

Podaliśmy skryptowi 13 wywołań narzędzi. Zapytał o 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 i o Edit pliku apps/web/package.json, a nie zareagował na npm install, npm install --ignore-scripts, npm ci && npm test, uv sync --frozen ani na Write do src/index.ts. Z DEP_GUARD_MODE=deny odrzucił pnpm add zod, co pasuje do przebiegów claude -p, w których nikt nie odpowie na pytanie. Gdy w PATH nie ma jq, strażnik blokuje wszystkie wywołania, zamiast wszystkie przepuszczać.

Luki, które zostawia hook, zamykają ustawienia zarządzane (managed settings): sandbox.enabled: true (z sandbox.failIfUnavailable: true, żeby maszyna bez sandboxa odmówiła startu zamiast działać bez niego), sandbox.network.allowedDomains i sandbox.network.allowManagedDomainsOnly: true ograniczają polecenia w sandboxie do hosta twojego proxy, więc skrypt, który sięga bezpośrednio do publicznego rejestru, dostaje odmowę. Ta blokada obejmuje tylko polecenia w sandboxie, więc ustaw też sandbox.allowUnsandboxedCommands: false. Sam hook wdrażaj przez ustawienia zarządzane, jeśli programiści nie mogą mieć możliwości jego usunięcia.

Gdy bramka zablokuje dodatek, ktoś musi zdecydować, czy pakiet trafi na allowlistę. Decyzja zajmuje dwie minuty, jeśli dowody są zebrane, a agent może je zebrać bez instalowania czegokolwiek.

Zastąp <PACKAGE> nazwą z linii błędu bramki.

Udowodnij to pull requestem red-team po wdrożeniu i po każdej aktualizacji menedżera pakietów, runnera albo agenta. Otwórz gałąź, która wprowadza każdą zmianę z tabeli osobnym commitem, i porównaj wynik z oczekiwanym.

Zmiana w gałęzi ćwiczeniaOczekiwany wynik
Dodanie prawdziwego pakietu z allowlistyBramka przechodzi
Dodanie prawdziwego pakietu spoza allowlistynew direct dependency is not in .github/dependency-allowlist.txt
Dodanie zależności bezpośredniej ze specyfikacją "latest"is not a pinned registry version
Ręczna edycja wpisu w lockfile’u, tak żeby resolved wskazywał inny hostBramka: resolved from …; lockfile-lint: detected invalid host(s)
Dodanie wpisu dla nazwy, która nie istniejedoes not exist on the registry
Dodanie pakietu opublikowanego po raz pierwszy mniej niż 90 dni temuless than 90 days ago
Dodanie wersji opublikowanej wczorajinside the 7-day cooldown
Dodanie pakietu na GPL-3.0 (albo innej licencji wykluczonej przez politykę)is not on the allowed list, a dependency review oblewa
Zmiana scripts/dep-gate.mjs na process.exit(0) w tej samej gałęziNic się nie zmienia: job uruchamia kopię z gałęzi bazowej, a CODEOWNERS wzywa zespół platformy
Prośba do agenta o npm install nowego pakietuClaude Code: pytanie o uprawnienie z nazwą polecenia; Codex: pytanie z uzasadnieniem reguły

Odpowiedzialność, żeby ćwiczenie miało właściciela:

RolaZa co odpowiadaJaki ślad zostawia
ProgramistaBlok instrukcji, hook albo plik reguł w repozytorium, prompt oceniający przy blokadzieDyskusja w pull requeście
Tech leadWorkflow jako wymagany check, ćwiczenie po aktualizacjach, wyjątki od cooldownuWyniki gałęzi ćwiczenia; pull request każdego wyjątku
Zespół bezpieczeństwa lub platformy (code owners)Allowlista, lista licencji, lista blokad w proxyHistoria allowlisty z zatwierdzającym i datą
CTOPolityka: które warstwy są obowiązkowe, długość okien, akceptowane licencjeDokument polityki i lista repozytoriów z wymaganą bramką

Z logów bramki śledź co miesiąc dwie liczby: ile pull requestów agentów bramka zablokowała i medianę czasu od blokady do decyzji. Rosnąca liczba blokad znaczy, że agenci coraz częściej sięgają po pakiety, a instrukcje albo allowlista wymagają pracy. Długi czas decyzji znaczy, że ludzie zaczną naciskać na obejścia.

Zespołom sprzedającym w UE historia allowlisty i log bramki służą też jako dowód należytej staranności wobec komponentów stron trzecich, której wymaga Cyber Resilience Act. Strona CRA, NIS2 i DORA-EU a zespoły z agentami przypisuje każdą kontrolę do przepisu i dodaje krok SBOM dla wydań.

Pilna poprawka bezpieczeństwa jest w oknie cooldownu. npm zostawia podatną wersję i kończy się kodem różnym od zera; pnpm i uv odmawiają jej rozwiązania. Naprawa: zwolnij ten jeden pakiet (min-release-age-exclude, minimumReleaseAgeExclude z dokładną wersją albo exclude-newer-package) w pull requeście z poprawką i usuń wyjątek, gdy wersja wyjdzie poza okno.

Agent usunął lockfile i zainstalował wszystko od nowa. Bramka zgłasza setki zmienionych wersji, wiele z nich w oknie cooldownu. Naprawa: przywróć lockfile z gałęzi bazowej, nałóż tylko zamierzoną zmianę przez npm install --package-lock-only albo uv lock i zostaw w instrukcjach agenta linię „nigdy nie generuj lockfile’a od nowa”.

Agent obchodzi hook. Zapisuje package.json jednolinijkowcem w Node albo Pythonie albo uruchamia npx some-tool lub uvx some-tool, które pobierają i uruchamiają pakiet w jednym kroku. Hook nie widzi żadnego z nich. Naprawa: w pull requeście nie ma czego naprawiać, bo bramka CI i tak sprawdza lockfile; na maszynie pobranie zatrzymuje allowlista sieci w sandboxie.

Allowlista zamienia się w pieczątkę. Każdy zablokowany pakiet ląduje na liście bez czytania dowodów. Naprawa: wymagaj w pull requeście do allowlisty tabeli z promptu oceniającego i trzymaj code ownerów w zespole, który czasem mówi „nie”.

Bramka pada na błędzie rejestru, a nie polityki. 401 albo 403 znaczy, że job nie ma odczytu z twojego proxy; 429 to limit zapytań przy bardzo dużej zmianie lockfile’a. Naprawa: daj CI odczyt z proxy i uruchom ponownie; bramka przy błędzie blokuje zmianę (fail closed), i o to chodzi.