Node.js環境でClaude Codeを使ってコーディングをしているとき、こんなことはありませんか?
- Claude Code がパッケージを追加するときに、bun のプロジェクトなのに
npm installを叩かれた。 - CLAUDE.md に「bun を使ってください」と書き足したら、今度は
pnpm addしてきた。 - 挙げ句の果てには package.json を直接編集して、古いバージョンを突っ込んできた。
お前は言うことを聞かんのか。
ただ、 Permissions.Deny で Bash(npm *) や Bash(pnpm *) を弾けばいいかというと、それだけでは足りません。
- エージェントは npm, pnpm など色々なパッケージマネージャを使おうとする。全パターンを deny に列挙し続けるのは
めんどくさい現実的でない - Bash コマンドを塞いでも、Edit/Write ツールで package.json を直接書き換える抜け道が残る
指示は破られるし、Deny だけでは塞ぎきれない。
しかし、Hook なら lockfile を見て不適切なパッケージマネージャーを判定でき、直接ファイル編集も含めてブロックできます。この記事では、実際に運用している 2 つの Hook を紹介します。
なお、Hook スクリプトは Node.js(.mjs)で書いています。私はWindows と Mac の両方で Claude Code を使っており、シェルスクリプトだと沼にハマってしまうので・・・(もう諦めました)。
1. 間違ったパッケージマネージャをブロックする
bun プロジェクトで npm install されると package-lock.json が生成されて lockfile が二重になり、node_modules との整合性も崩れます。そこで、lockfile を見て正しいパッケージマネージャを判定し、違うものが来たらブロックします。
| lockfile | 許可 | ブロック |
|---|---|---|
| bun.lock | bun / bunx | npm, npx, pnpm, pnpx |
| package-lock.json | npm / npx | bun, bunx, pnpm, pnpx |
| pnpm-lock.yaml | pnpm / pnpx | npm, npx, bun, bunx |
settings.json にはこう書きます。~/.claude/settings.json に置けば全プロジェクトに適用されます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node /path/to/enforce-package-manager.mjs"
}
]
}
]
}
}
スクリプト本体はこちら。
#!/usr/bin/env node
import { readFileSync, existsSync } from "node:fs";
function deny(reason) {
console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason,
},
})
);
process.exit(0);
}
const input = JSON.parse(readFileSync(0, "utf-8") || "{}");
const command = input.tool_input?.command || "";
const pmPatterns = {
npm: /(?:^|&&|;|\||\$\()\s*(?:npm|npx)\s+/i,
bun: /(?:^|&&|;|\||\$\()\s*(?:bun|bunx)\s+/i,
pnpm: /(?:^|&&|;|\||\$\()\s*(?:pnpm|pnpx)\s+/i,
};
const lockfiles = {
"bun.lock": { allow: "bun", block: ["npm", "pnpm"], name: "bun" },
"package-lock.json": { allow: "npm", block: ["bun", "pnpm"], name: "npm" },
"pnpm-lock.yaml": { allow: "pnpm", block: ["npm", "bun"], name: "pnpm" },
};
const detected = Object.entries(lockfiles).find(([file]) => existsSync(file));
if (!detected) process.exit(0);
const [, config] = detected;
for (const blocked of config.block) {
if (pmPatterns[blocked].test(command)) {
deny(`このプロジェクトでは ${config.name} を使用してください(${detected[0]} を検出)。`);
}
}
&& や ; でチェーンされたコマンドも正規表現で拾います。ブロック時は process.exit(0) で抜けます(非ゼロだと Hook 自体のエラー扱いになるので注意)。
2. package.json の deps を直接いじらせない
Claude Code はあほなので、パッケージマネージャのコマンドを使わず、Edit/Write ツールで package.json を直接書き換えてくることがあります。こうなると lockfile は更新されないし、バージョンも古いものになります。
しかも、そのまま動いちゃうのでぼーっとしているとバージョンが非常に古いままになるということが発生します。いい加減にしろ。Renovate で気が付いて、アップデート破壊的にならないよな・・・とか気を遣うのはもうやめたい。
そこで、scripts 等の編集は通しつつ、dependencies / devDependencies への編集だけを Hooks でブロックします。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node /path/to/deny-package-json-deps-edit.mjs"
}
]
}
]
}
}
スクリプト本体はこちら。
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function deny(reason) {
console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason,
},
})
);
process.exit(0);
}
const input = JSON.parse(readFileSync(0, "utf-8") || "{}");
const toolInput = input.tool_input || {};
const filePath = toolInput.file_path || "";
if (!filePath.endsWith("package.json")) {
process.exit(0);
}
// Write は package.json 全体の書き換えになるため無条件ブロック
if (input.tool_name === "Write") {
deny(
"package.json 全体の書き換えは禁止です。パッケージマネージャの add / remove コマンドを使用してください。"
);
}
// Edit は編集箇所が deps セクション内かを判定
const oldString = toolInput.old_string || "";
try {
const absPath = resolve(filePath);
const content = readFileSync(absPath, "utf-8");
const editPos = content.indexOf(oldString);
if (editPos === -1) process.exit(0);
const sections = ["dependencies", "devDependencies"];
for (const section of sections) {
const sectionKeyPos = content.indexOf(`"${section}":`);
if (sectionKeyPos === -1) continue;
const braceStart = content.indexOf("{", sectionKeyPos);
if (braceStart === -1) continue;
let depth = 0;
let braceEnd = -1;
for (let i = braceStart; i < content.length; i++) {
if (content[i] === "{") depth++;
if (content[i] === "}") depth--;
if (depth === 0) {
braceEnd = i;
break;
}
}
if (editPos >= sectionKeyPos && editPos <= braceEnd) {
deny(
`package.json の ${section} を直接編集しないでください。\n` +
"パッケージマネージャの add / remove コマンドで追加・削除してください。\n" +
"バージョンは学習データの記憶に頼らず、未指定で最新版をインストールしてください。\n" +
"特定バージョンが必要な場合は事前に確認してください。"
);
}
}
} catch {
// ファイル読み取りエラー時は許可(安全側に倒す)
}
やっていることは、Edit ツールの old_string が package.json 内のどこにあるかを indexOf で調べて、それが dependencies / devDependencies の {} の中だったらブロック、というシンプルな判定です。JSON パーサーを使わず文字列走査しているのは、パースすると位置情報が消えるからです。
まとめ
| 防ぎたいこと | Hook | やっていること |
|---|---|---|
| 違うパッケージマネージャの使用 | enforce-package-manager | lockfile から判定し、違うコマンドをブロック |
| package.json の直接編集 | deny-package-json-deps-edit | deps セクションへの Edit/Write をブロック |
これで、もう勝手に npm install されることはなくなりました。言うことを聞かないなら、聞かざるを得ない仕組みを作るのです。
(もっといいやり方があったら教えてください笑)