夜のうちにエージェントへ振っておいたタスクが、朝には終わっていました。テストも通っています。差分を眺めていて、package.json に一行増えているのに気づきました。CLI の引数を解釈する小さなライブラリです。週次ダウンロードは四桁、公開は11日前。
動作としては何の問題もありません。ただ、その依存が node_modules に展開されるとき、私の手元では postinstall スクリプトが一度実行されています。エージェントは「テストを通す」という仕事を果たしただけで、そのスクリプトの中身を検分する仕事は誰にも割り当てられていませんでした。
個人開発で複数のアプリと Stripe 課金を載せたサイト群を並行して回していると、依存を足す判断の回数だけが静かに増えていきます。同じ手元から App Store に出す成果物もビルドされます。だからこそ、この一行が気になりました。
npm install の一行は、コードの取得ではなく、コードの実行を含みます。手で依存を足していた頃は、その一行を打つ前に名前を検索するくらいの間がありました。エージェントに任せると、その間が消えます。
npm install が実行しているもの
npm のライフサイクルスクリプトのうち、依存パッケージ側で走りうるのは preinstall / install / postinstall の三つです。重要なのは、これが直接依存だけの話ではないという点です。深い階層の推移的依存が持つスクリプトも、同じ権限で、同じ実行ユーザーで走ります。
つまり手元で守るべき境界は「私が選んだパッケージ」ではなく「私が選んだパッケージが引き連れてきたツリー全体」です。エージェントに依存追加を任せると、この境界は一晩で広がります。
| フック | 走るタイミング | 典型的な正当用途 |
preinstall | そのパッケージの展開前 | 環境チェック(ほぼ不要) |
install | 展開直後 | ネイティブアドオンのビルド |
postinstall | install の後 | バイナリのダウンロード、パッチ適用 |
正当な用途は確かにあります。ネイティブアドオンや、プラットフォーム別バイナリを取得するツールは、これがないと動きません。ですから「全部禁止」は運用として続きません。続くのは「既定で止めて、必要なものだけ名指しで通す」という形です。
既定を反転する
パッケージマネージャによって、既定値と設定の場所が違います。手元の4つのリポジトリを揃えたときの実際の設定を並べます。
| ツール | 依存スクリプトの既定 | 止める / 通す方法 |
| npm | 実行する | .npmrc に ignore-scripts=true。通すものは個別に手で実行 |
| pnpm (v10 以降) | 実行しない | package.json の pnpm.onlyBuiltDependencies に列挙したものだけ実行 |
| Yarn (Berry) | 実行しない | .yarnrc.yml の enableScripts / dependenciesMeta |
pnpm を使っているなら、既定が既に安全側です。allowlist を明示するだけで済みます。
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp", "@swc/core"]
}
}
npm 側は既定を反転させます。
# .npmrc(リポジトリ直下・コミットする)
ignore-scripts=true
ここで素直に困るのが、ignore-scripts=true にすると壊れるパッケージがある点です。sharp や esbuild のようにバイナリを取りに行くものは、そのままでは実行時に落ちます。
注意点は、落ち方が分かりにくいことです。インストールは成功します。エラーは出ません。そしてアプリを起動した瞬間に「バイナリが見つからない」と言われます。本番運用のビルドで初めて気づく形になりやすく、私はこの静かな失敗の仕方をいちばん警戒しています。
そこで順序を逆にします。壊れてから探すのではなく、止まるものを先に数えます。私が4リポジトリで踏んだ手順はこの三つでした。
- スクリプトを持つ依存をツリー全体から棚卸しする(実装1)
- その一覧を allowlist と「落とす候補」に仕分ける
- 既定を反転し、allowlist だけを通す
この順で進めると、壊れる範囲が作業前に分かります。逆順で進めた最初の一回は、原因の切り分けだけで半日を費やしました。
実装1:スクリプトを持つ依存を棚卸しする
npm レジストリの簡略メタデータには hasInstallScript が含まれます。ツリー全体を舐めて、スクリプトを持つパッケージだけを抜き出します。
// scripts/list-install-scripts.mjs
// 使い方: node scripts/list-install-scripts.mjs
import { readFile } from "node:fs/promises";
const ABBREV = "application/vnd.npm.install-v1+json";
async function hasInstallScript(name, version) {
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
headers: { accept: ABBREV },
});
if (!res.ok) return { name, version, unknown: true };
const doc = await res.json();
const v = doc.versions?.[version];
return { name, version, hasInstallScript: Boolean(v?.hasInstallScript) };
}
// npm ls --all --json > tree.json で作った出力を読む
function flatten(node, out = new Map()) {
for (const [name, dep] of Object.entries(node.dependencies ?? {})) {
if (dep.version && !out.has(`${name}@${dep.version}`)) {
out.set(`${name}@${dep.version}`, { name, version: dep.version });
flatten(dep, out);
}
}
return out;
}
const tree = JSON.parse(await readFile("tree.json", "utf8"));
const all = [...flatten(tree).values()];
const results = [];
for (const { name, version } of all) {
results.push(await hasInstallScript(name, version));
}
const withScripts = results.filter((r) => r.hasInstallScript);
console.log(`依存合計: ${all.length} / スクリプト保持: ${withScripts.length}`);
for (const r of withScripts) console.log(` ${r.name}@${r.version}`);
このスクリプトを最初に走らせたとき、あるリポジトリの依存 612 個のうち、インストール時スクリプトを持っていたのは 9 個でした。1.5% です。
この数字を見て、方針が決まりました。9 個なら名指しで許可できます。612 個を信頼するか 9 個を検分するかであれば、後者を選ぶ理由は十分にあります。
実装2:新規依存に検疫スコアを付ける
allowlist を整えても、エージェントが明日また新しい依存を足せば、同じ場所に戻ります。必要なのは、追加された依存を機械的に見立てる仕組みです。
四つの観測値を使いました。どれも公開 API から取れます。
| 観測値 | 取得元 | 何を疑うか |
| 公開からの日数 | packument の time.created | 作りたてのパッケージ |
| 週次ダウンロード数 | downloads API | 誰も使っていないもの |
| 名前の編集距離 | 手元の著名パッケージ一覧と比較 | タイポスクワット |
| インストール時スクリプト | hasInstallScript | 展開時に走るコード |
// scripts/quarantine-score.mjs
// 使い方: node scripts/quarantine-score.mjs lodash-es minimist
const ABBREV = "application/vnd.npm.install-v1+json";
// 手元で実際に使っている・広く使われている名前を並べる(タイポスクワットの比較対象)
const KNOWN = ["lodash", "react", "next", "express", "axios", "zod", "minimist", "chalk", "dotenv"];
function editDistance(a, b) {
const d = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
);
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
}
}
return d[a.length][b.length];
}
function nearestKnown(name) {
let best = { name: null, distance: Infinity };
for (const k of KNOWN) {
if (k === name) return { name: k, distance: 0 };
const distance = editDistance(name, k);
if (distance < best.distance) best = { name: k, distance };
}
return best;
}
async function inspect(name) {
const [full, abbrev, dl] = await Promise.all([
fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`).then((r) => r.json()),
fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
headers: { accept: ABBREV },
}).then((r) => r.json()),
fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`)
.then((r) => r.json())
.catch(() => ({ downloads: 0 })),
]);
const latest = full["dist-tags"]?.latest;
const ageDays = Math.floor((Date.now() - Date.parse(full.time?.created ?? 0)) / 86400000);
const weekly = dl.downloads ?? 0;
const near = nearestKnown(name);
const scripted = Boolean(abbrev.versions?.[latest]?.hasInstallScript);
// 加点方式。高いほど人間が見るべき
let score = 0;
const reasons = [];
if (ageDays < 30) { score += 3; reasons.push(`公開 ${ageDays} 日`); }
else if (ageDays < 180) { score += 1; reasons.push(`公開 ${ageDays} 日`); }
if (weekly < 1000) { score += 3; reasons.push(`週次DL ${weekly}`); }
else if (weekly < 50000) { score += 1; reasons.push(`週次DL ${weekly}`); }
if (near.distance > 0 && near.distance <= 2) {
score += 4;
reasons.push(`"${near.name}" と編集距離 ${near.distance}`);
}
if (scripted) { score += 2; reasons.push("インストール時スクリプトあり"); }
return { name, version: latest, score, reasons };
}
const targets = process.argv.slice(2);
let blocked = 0;
for (const name of targets) {
const r = await inspect(name);
const verdict = r.score >= 5 ? "要確認" : "通過";
if (r.score >= 5) blocked++;
console.log(`[${verdict}] ${r.name}@${r.version} score=${r.score}`);
for (const reason of r.reasons) console.log(` - ${reason}`);
}
process.exit(blocked > 0 ? 1 : 0);
閾値を 5 にしたのには理由があります。「新しいだけ」(3点)や「使われていないだけ」(3点)の単独では止めません。個人開発では、ニッチで正当な小さいパッケージを使う場面が普通にあるからです。止めたいのは重なったときです。新しく、かつ誰も使っておらず(6点)、あるいは有名パッケージと1文字違い(4点)でスクリプトを持つ(2点)。この組み合わせは、手を止めて中身を見るに値します。
タイポスクワットの重み 4 は他より高くしています。名前の1文字違いは偶然では起きにくく、起きたときの意味が最も悪いためです。
KNOWN には著名パッケージだけでなく、自分が実際に使っている名前を入れることを推奨します。守りたいのは世間一般の有名どころではなく、自分の package.json に既にある名前だからです。
実装3:無人ランに差し込む
検疫は、人が差分を眺めるときではなく、エージェントがコミットする直前に効かせます。package.json の差分から新規依存だけを抜き、スコアを付けます。
#!/usr/bin/env bash
# scripts/gate-new-deps.sh
set -euo pipefail
# ステージされた package.json の差分から、追加された依存名だけを取る
ADDED=$(git diff --cached -U0 -- package.json \
| grep -E '^\+\s+"[^"]+":' \
| sed -E 's/^\+\s+"([^"]+)".*/\1/' \
| grep -vE '^(name|version|description|scripts|type|private|license)$' || true)
if [ -z "$ADDED" ]; then
echo "新規依存なし"
exit 0
fi
echo "新規依存を検疫します:"
echo "$ADDED" | sed 's/^/ /'
# shellcheck disable=SC2086
if ! node scripts/quarantine-score.mjs $ADDED; then
echo ""
echo "❌ 検疫で要確認が出ました。エージェントの判断では通しません。"
echo " 中身を確認のうえ、意図的に通す場合は QUARANTINE_ACK=1 を付けて再実行してください。"
[ "${QUARANTINE_ACK:-0}" = "1" ] || exit 1
echo "⚠️ QUARANTINE_ACK により手動で通過"
fi
無人ランでは QUARANTINE_ACK を渡しません。ここが設計の要点です。エージェントは検疫を通過する依存だけを足せます。通過しないものに出会ったら、そのタスクは止まり、朝の私に判断が回ってきます。
止める判断は自動化し、通す判断は手元に残す。この非対称は、エージェント運用のほぼ全ての場面で効きます。エージェントが「大丈夫そうです」と自己申告できてしまう経路を作らないことが、この種のゲートの価値です。
導入してから1ヶ月
4リポジトリで1ヶ月回した結果です。
| 項目 | 件数 | 備考 |
| エージェントが足した新規依存 | 31 | 4リポジトリ合計 |
| 自動通過(score < 5) | 27(87%) | 手を止めずに進行 |
| 要確認(score ≥ 5) | 4(13%) | — |
| うち、確認後に通したもの | 3 | ニッチだが正当な小規模パッケージ |
| うち、別のパッケージに替えたもの | 1 | 公開9日・週次DL 200台・スクリプトあり |
止まった4件のうち3件は、結果として問題ありませんでした。誤検知が 4 件中 3 件と聞くと精度が低いように見えます。それでも運用として成立しているのは、止まる回数が月4回だからです。月4回の確認で、31 回の無人判断のうち見るべき箇所が絞れるなら、費用対効果は釣り合います。
判定を厳しくして誤検知を減らそうとすると、閾値が上がって本当に見たいものを逃します。ここは精度より、確認の回数が現実的な水準に収まるかで決めるべきだと考えています。
| 指標 | 導入前 | 導入後 |
| インストール時スクリプトを実行する依存 | 9 | 3(allowlist のみ) |
| 新規依存が入るまでに人が見る機会 | 0 回 | score ≥ 5 のとき 1 回 |
| ゲートの実行時間 | — | 1 依存あたり約 0.4 秒 |
ignore-scripts=true にしてから壊れた依存は、sharp と esbuild を含む 6 個でした。allowlist に 3 個を入れ、残る 3 個は依存自体を落として解決しました。落とせたのは、そのうち 2 個が既に別のパッケージと機能が重複していたためです。検疫を入れる作業が、そのまま依存の棚卸しになりました。
どこまでを自動で止めるか
この仕組みで防げるのは、インストール時に走るコードと、名前の紛らわしいパッケージの混入です。防げないものもはっきりしています。実行時に読み込まれてから悪さをするコード、正当なパッケージが乗っ取られる形の侵害、レジストリより上流での改ざん。これらは別の層で見る必要があります。
それでも、この層に置く価値はあると考えています。エージェントに任せた作業で最も薄くなるのは「手が止まる瞬間」です。人間が npm install を打つときには、たとえ無意識でも名前を一度読みます。エージェントにはその工程がありません。失われた工程を、機械で置き直したというのが、この設計の実態です。
まずは手元のリポジトリで、実装1の棚卸しだけを走らせてみてください。依存の総数と、スクリプトを持つ数。この二つの比率が出た時点で、次に何をすべきかは自ずと決まると思います。私の場合は 612 と 9 でした。