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.
Co daje bramkowanie zmian zależności od agentów
Dział zatytułowany „Co daje bramkowanie zmian zależności od agentów”- 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 checki 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:
| Ryzyko | Co robi agent | Dlaczego review tego nie widzi |
|---|---|---|
| Zmyślona nazwa | Importuje pakiet, który nie istnieje albo który atakujący zarejestrował, bo modele ciągle go wymyślały | Nazwa brzmi wiarygodnie; po rejestracji instalacja przechodzi |
| Typosquat | Pisze reqeusts zamiast requests albo wybiera nie tę z dwóch podobnych nazw | Jedna przestawiona litera w diffie lockfile’a na 1400 linii |
| Świeże przejęcie | Uruchamia npm install kilka minut po przejęciu konta opiekuna i publikacji złej wersji | Pakiet jest znany; nowa jest tylko wersja |
| Brak przypięcia albo źródło spoza rejestru | Wpisuje "latest", zakres bez lockfile’a, URL gita albo URL tarballa | Linia w manifeście wygląda niewinnie; host widać w lockfile’u tylko temu, kto tam zajrzy |
| Dryf licencji | Wciąga zależność przechodnią na licencji, której polityka nie dopuszcza | Licencji 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.
Która warstwa łapie który błąd zależności?
Dział zatytułowany „Która warstwa łapie który błąd zależności?”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.
| Warstwa | Gdzie działa | Zmyślona nazwa | Typosquat | Świeże przejęcie | Brak przypięcia lub obce źródło | Dryf licencji | Obejmuje agentów w chmurze? |
|---|---|---|---|---|---|---|---|
| 1. Bramka CI na diffie lockfile’a | Każdy pull request | Tak | Tak, przez allowlistę i wiek nazwy | Tak, przez cooldown | Tak | Tak | Tak |
| 2. Cooldown w commitowanej konfiguracji | Każda instalacja, która czyta konfigurację repozytorium | Częściowo: nazwa zarejestrowana w zeszłym tygodniu jest niewidoczna | Częściowo | Tak | Nie | Nie | Tak, jeśli agent używa aktualnego menedżera pakietów |
| 3. Proxy rejestru | Każda instalacja w twojej sieci | Częściowo | Listy blokad | Tak, z filtrem wieku | Tak, jeśli klienci nie mają dostępu do publicznego rejestru | Nie | Tylko jeśli sieć agenta jest przypięta do proxy |
| 4. Hook lub reguła zatwierdzająca w agencie | Sesja programisty | Pyta człowieka | Pyta człowieka | Nie | Pyta człowieka | Nie | Nie |
| 5. Blokowanie skryptów instalacyjnych | Każda instalacja | Nie | Nie | Zatrzymuje ładunek w postinstall | Nie | Nie | Tak |
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ą.
Wdrożenie weryfikacji zależności krok po kroku
Dział zatytułowany „Wdrożenie weryfikacji zależności krok po kroku”-
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. -
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łapkiCODEOWNERSopisuje strona o ochronie wyroczni. -
Włącz cooldown i blokowanie skryptów w commitowanej konfiguracji menedżera pakietów (następna sekcja).
-
Dodaj bramkę na diffie lockfile’a do CI i ustaw ją jako wymagany check.
-
Dodaj zatwierdzanie po stronie agenta w każdym narzędziu, którego używa zespół.
-
Skieruj instalacje na proxy rejestru, gdy z tych samych zasad korzysta więcej niż jeden zespół.
-
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=7save-exact=truePrzegląd i zatwierdzanie skryptów instalacyjnych:
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 czyta te ustawienia z pnpm-workspace.yaml. Od pnpm 11 minimumReleaseAge domyślnie wynosi 1440 minut (jeden dzień); dłuższe okno ustaw jawnie. strictDepBuilds domyślnie ma wartość true, więc instalacja kończy się błędem, gdy zależność ma skrypt budujący nieobjęty allowBuilds. Sprawdzone w dokumentacji ustawień pnpm 26 września 2026 roku (aktualna wersja pnpm 12.6.0).
# 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 w nowszych wydaniach uv przyjmuje czas trwania. Uruchomiliśmy uv lock z poniższym ustawieniem na uv 0.12.19; uv 0.8.17 odrzuca zapis czasu trwania („could not be parsed as a valid date”), więc zaktualizuj uv, zanim zaczniesz na tym polegać. uv zapisuje okno w 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 }Cooldown nie zatrzyma nazwy slopsquattingowej zarejestrowanej kilka tygodni temu i nie widzi manifestu, który wskazuje na URL gita. To zadanie warstwy 1.
Bramka CI na diffie lockfile’a
Dział zatytułowany „Bramka CI na diffie lockfile’a”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 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);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.
uv.lock zapisuje czas wgrania każdego pliku, więc cooldown sprawdza się offline; kontrola nowej nazwy pyta API JSON w stylu PyPI, czyli publiczne PyPI, chyba że DEP_PYPI_JSON wskazuje API twojego proxy. Uruchomiliśmy skrypt na Pythonie 3.11 na lockfile’ach z uv 0.8.17: niezatwierdzona zależność bezpośrednia, nazwa, dla której PyPI zwraca 404, plik wgrany dzień wcześniej i pakiet ze źródła URL oblały kontrolę; dodatek z allowlisty przeszedł. uv.lock nie zapisuje licencji, więc sprawdza je dependency review albo OSV-Scanner (zob. uwagi pod workflowem).
#!/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)Ustaw DEP_INDEX na prosty indeks twojego proxy, a DEP_PYPI_JSON na bazowy adres jego API JSON, inaczej kontrola nowej nazwy oblewa w jobie CI, który nie sięga do pypi.org.
Workflow
Dział zatytułowany „Workflow”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ół.
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: 3Uwagi do jobów:
lockfile-lintłapie to, co może ukryć ręcznie edytowany lockfile: adresresolvedna 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 hostevil.examplew podrobionym. Zamieńnpmw--allowed-hostsna nazwę hosta twojego proxy.actions/dependency-review-action@v5oblewa znane podatności i licencje spozaallow-licensesoraz 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 showprzerywa job, dopóki gałąź bazowa ich nie zawiera, więc skrypty, allowlistę iCODEOWNERSwprowadź pierwszym pull requestem. Usuń kroki dla ekosystemu, którego nie używasz. - Bez kroku instalacji, więc wystarcza
contents: read.npm cialbouv sync --lockedw 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.
Przypnij każdą instalację do proxy rejestru
Dział zatytułowany „Przypnij każdą instalację do proxy rejestru”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: 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'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 = trueSocket 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.
Niech agent pyta, zanim doda zależność
Dział zatytułowany „Niech agent pyta, zanim doda zależność”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 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 0Podaliś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.
Plik reguł Codex z decision = "prompt" sprawia, że Codex pyta przed uruchomieniem pasującego polecenia. Sprawdziliśmy ten plik przez codex execpolicy check na Codex CLI 0.157.1, 26 września 2026 roku: npm install left-pad, npm i, pnpm add zod, uv add requests, uv pip install x i pip install requests zwróciły "decision":"prompt" z uzasadnieniem; npm ci, npm test, uv pip list i uv sync --frozen nie pasowały do żadnej reguły. Reguła prefiksowa nie odróżnia gołego npm install od takiego, które podaje nazwę pakietu, więc Codex pyta też przy zwykłej reinstalacji z lockfile’a, którą hook Claude Code przepuszcza.
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")Swoją kopię przetestuj tak samo, zanim ją zacommitujesz:
# Terminal, Codex CLI 0.157.1codex execpolicy check --rules .codex/rules/dependencies.rules npm install left-padReguła prefiksowa dopasowuje polecenie, nie plik, więc edycja package.json przez apply_patch nie jest nią objęta; obejmują ją bramka CI i CODEOWNERS na manifestach. Żeby reguły wiązały cały zespół, administrator umieszcza je w requirements.toml, który przyjmuje tylko decyzje prompt albo forbidden; tę postać pokazuje strona o unijnych regulacjach oprogramowania. Codex ma też hooki PreToolUse (12 zdarzeń hooków w 0.157.1); jeśli chcesz kontroli edycji manifestów także w sesji, przenieś logikę skryptu z Claude Code na schemat hooków Codex.
Cursor ma hooki, które „run before or after defined stages of the agent loop and can observe, block, or modify behavior” (cursor.com/docs/hooks, sprawdzone 28 sierpnia 2026 roku), więc wzorce ze skryptu Claude Code da się przenieść. 26 września 2026 roku nie mogliśmy ponownie sprawdzić nazw zdarzeń hooków ani pliku konfiguracyjnego Cursora (cursor.com był niedostępny z naszego środowiska); weź je z dokumentacji hooków Cursora.
Cloud Agents w Cursorze „run in isolated VMs in the cloud with full development environments” (sprawdzone 28 sierpnia 2026 roku), więc nic z twojego laptopa ich nie dotyczy. W Cursorze kontrolą jest to, co podróżuje z repozytorium: commitowane ustawienia cooldownu i rejestru oraz bramka CI.
Jak sprawdzić zależność, którą proponuje agent?
Dział zatytułowany „Jak sprawdzić zależność, którą proponuje agent?”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.
Skąd wiesz, że bramka zależności działa?
Dział zatytułowany „Skąd wiesz, że bramka zależności działa?”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 ćwiczenia | Oczekiwany wynik |
|---|---|
| Dodanie prawdziwego pakietu z allowlisty | Bramka przechodzi |
| Dodanie prawdziwego pakietu spoza allowlisty | new 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 host | Bramka: resolved from …; lockfile-lint: detected invalid host(s) |
| Dodanie wpisu dla nazwy, która nie istnieje | does not exist on the registry |
| Dodanie pakietu opublikowanego po raz pierwszy mniej niż 90 dni temu | less than 90 days ago |
| Dodanie wersji opublikowanej wczoraj | inside 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łęzi | Nic się nie zmienia: job uruchamia kopię z gałęzi bazowej, a CODEOWNERS wzywa zespół platformy |
Prośba do agenta o npm install nowego pakietu | Claude Code: pytanie o uprawnienie z nazwą polecenia; Codex: pytanie z uzasadnieniem reguły |
Odpowiedzialność, żeby ćwiczenie miało właściciela:
| Rola | Za co odpowiada | Jaki ślad zostawia |
|---|---|---|
| Programista | Blok instrukcji, hook albo plik reguł w repozytorium, prompt oceniający przy blokadzie | Dyskusja w pull requeście |
| Tech lead | Workflow jako wymagany check, ćwiczenie po aktualizacjach, wyjątki od cooldownu | Wyniki gałęzi ćwiczenia; pull request każdego wyjątku |
| Zespół bezpieczeństwa lub platformy (code owners) | Allowlista, lista licencji, lista blokad w proxy | Historia allowlisty z zatwierdzającym i datą |
| CTO | Polityka: które warstwy są obowiązkowe, długość okien, akceptowane licencje | Dokument 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ń.
Co się psuje, gdy bramkujesz zależności agentów?
Dział zatytułowany „Co się psuje, gdy bramkujesz zależności agentów?”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.