Skip to content

Instantly share code, notes, and snippets.

@f440
Created March 27, 2026 03:05
Show Gist options
  • Select an option

  • Save f440/bcabbfd8935284fb60a5b5e9dea37a5a to your computer and use it in GitHub Desktop.

Select an option

Save f440/bcabbfd8935284fb60a5b5e9dea37a5a to your computer and use it in GitHub Desktop.
import { describe, test, expect } from "bun:test";
import { getField, testPattern, evaluate } from "./pre-tool-use";
import type { Rule, HookInput } from "./pre-tool-use";
// TOML からルールとテストケースをロード(トップレベル await)
// @ts-ignore: import.meta.dir は Bun 固有の拡張
const tomlText = await Bun.file(import.meta.dir + "/pre-tool-use.toml").text();
const allRules = (Bun.TOML.parse(tomlText) as { rules: Rule[] }).rules;
// ============================================================
// getField
// ============================================================
describe("getField", () => {
test("トップレベルのフィールドを取得する", () => {
expect(getField({ tool_name: "Bash" }, "tool_name")).toBe("Bash");
});
test("ネストしたフィールドをドット記法で取得する", () => {
const obj = { tool_input: { command: "ls -la" } };
expect(getField(obj, "tool_input.command")).toBe("ls -la");
});
test("3段階のネストでも取得できる", () => {
const obj = { a: { b: { c: "deep" } } };
expect(getField(obj, "a.b.c")).toBe("deep");
});
test("存在しないフィールドは undefined を返す", () => {
expect(getField({ tool_name: "Bash" }, "tool_input.command")).toBeUndefined();
});
test("null 値は undefined を返す", () => {
expect(getField({ key: null }, "key")).toBeUndefined();
});
test("数値は文字列に変換される", () => {
expect(getField({ count: 42 }, "count")).toBe("42");
});
test("途中のパスが null の場合は undefined を返す", () => {
expect(getField({ a: null }, "a.b")).toBeUndefined();
});
});
// ============================================================
// testPattern
// ============================================================
describe("testPattern", () => {
test("パターンにマッチする", () => {
expect(testPattern("Bash", "^Bash$")).toBe(true);
});
test("パターンにマッチしない", () => {
expect(testPattern("Read", "^Bash$")).toBe(false);
});
test("正規表現のメタ文字が使える", () => {
expect(testPattern("git add .", "\\bgit\\s+add\\b")).toBe(true);
});
test("無効な正規表現は false を返す(例外を投げない)", () => {
expect(testPattern("test", "[invalid")).toBe(false);
});
test("部分マッチもマッチとみなされる", () => {
expect(testPattern("git add --all", "--all")).toBe(true);
});
});
// ============================================================
// evaluate
// ============================================================
describe("evaluate", () => {
const input = (command: string): HookInput => ({
tool_name: "Bash",
tool_input: { command },
});
const sudoRule: Rule = {
name: "Block sudo",
reason: "sudo は許可されていません",
conditions: [
{ field: "tool_name", pattern: "^Bash$" },
{ field: "tool_input.command", pattern: "\\bsudo\\b" },
],
};
test("全条件にマッチしたら reason を返す", () => {
expect(evaluate(input("sudo rm -rf /"), [sudoRule])).toBe("sudo は許可されていません");
});
test("一部の条件しかマッチしない場合は null を返す", () => {
expect(evaluate(input("ls -la"), [sudoRule])).toBeNull();
});
test("tool_name がマッチしない場合は null を返す", () => {
const readInput: HookInput = { tool_name: "Read", tool_input: { file_path: "/etc/hosts" } };
expect(evaluate(readInput, [sudoRule])).toBeNull();
});
test("ルールが空なら null を返す", () => {
expect(evaluate(input("sudo rm"), [])).toBeNull();
});
test("複数ルールのうち最初にマッチしたものの reason を返す", () => {
const publishRule: Rule = {
name: "Block publish",
reason: "publish は禁止",
conditions: [
{ field: "tool_name", pattern: "^Bash$" },
{ field: "tool_input.command", pattern: "\\bnpm publish\\b" },
],
};
expect(evaluate(input("sudo npm publish"), [sudoRule, publishRule])).toBe(
"sudo は許可されていません"
);
expect(evaluate(input("npm publish"), [sudoRule, publishRule])).toBe("publish は禁止");
});
test("conditions がないルールはスキップされる", () => {
const malformed = { name: "bad", reason: "bad" } as unknown as Rule;
expect(evaluate(input("anything"), [malformed, sudoRule])).toBeNull();
});
test("conditions が空配列のルールはスキップされる", () => {
const empty: Rule = { name: "empty", reason: "全ブロック危険", conditions: [] };
expect(evaluate(input("anything"), [empty])).toBeNull();
});
});
// ============================================================
// TOML 駆動統合テスト
// 各ルールの tests[] セクションをそのままテストケースとして使用する。
// evaluate はそのルール単体で評価し、他ルールの影響を受けない。
// ============================================================
interface TestCase {
tool_name: string;
tool_input: Record<string, unknown>;
blocked: boolean;
}
interface RuleWithTests extends Rule {
tests?: TestCase[];
}
for (const rule of allRules as RuleWithTests[]) {
if (!rule.tests?.length) continue;
describe(`ルール: ${rule.name}`, () => {
for (const tc of rule.tests!) {
const input: HookInput = { tool_name: tc.tool_name, tool_input: tc.tool_input };
const label = JSON.stringify(tc.tool_input);
test(`${tc.blocked ? "BLOCK" : "ALLOW"} ${label}`, () => {
const result = evaluate(input, [rule]);
if (tc.blocked) {
expect(result).not.toBeNull();
} else {
expect(result).toBeNull();
}
});
}
});
}
// ============================================================
// E2E テスト: スクリプトをサブプロセスで実行して exit code を検証
// ============================================================
describe("E2E: スクリプト実行", () => {
// @ts-ignore: import.meta.dir は Bun 固有の拡張
const script = import.meta.dir + "/pre-tool-use.ts";
async function run(input: object): Promise<{ exitCode: number; stderr: string }> {
const proc = Bun.spawn(["bun", "run", script], {
stdin: new TextEncoder().encode(JSON.stringify(input)),
stderr: "pipe",
});
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
return { exitCode, stderr };
}
test("ブロック対象コマンドは exit 2 で reason を stderr に出力する", async () => {
const { exitCode, stderr } = await run({
tool_name: "Bash",
tool_input: { command: "git add ." },
});
expect(exitCode).toBe(2);
expect(stderr).toContain("git add");
});
test("許可対象コマンドは exit 0 で stderr は空", async () => {
const { exitCode, stderr } = await run({
tool_name: "Bash",
tool_input: { command: "git add src/foo.ts" },
});
expect(exitCode).toBe(0);
expect(stderr).toBe("");
});
test("不正な JSON 入力は fail-open (exit 0)", async () => {
const proc = Bun.spawn(["bun", "run", script], {
stdin: new TextEncoder().encode("not json"),
stderr: "pipe",
});
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
});
});
# Claude Code preToolUse hook 設定
#
# ルール構造:
# [[rules]]
# name = "ルール名(ログ・デバッグ用)"
# reason = "ブロック時にユーザーへ表示するメッセージ"
#
# [[rules.conditions]]
# field = "ドット記法のフィールドパス"
# # 例: "tool_name", "tool_input.command", "tool_input.file_path"
# pattern = "JavaScript 正規表現パターン"
#
# [[rules.tests]]
# tool_name = "ツール名"
# tool_input = { command = "コマンド" } # インラインテーブルでフィールドを指定
# blocked = true # このルール単体でブロックされるか
#
# 評価ルール:
# - 1ルール内の conditions は AND(全条件一致でブロック)
# - 複数ルールは OR(どれか1つがマッチすればブロック)
# - 最初にマッチしたルールの reason が表示される
# - 設定ファイルが存在しない or 読み込み失敗時は全ツールを許可(fail-open)
#
# 利用可能なフィールド (tool_name 別):
# Bash: tool_input.command
# Write: tool_input.file_path, tool_input.content
# Edit / MultiEdit: tool_input.file_path
# Read: tool_input.file_path
# WebFetch: tool_input.url
# WebSearch: tool_input.query
#
# 注意: Bun の TOML パーサーのバグにより、"#" だけの空コメント行が
# [[rules]] の直前にあると配列テーブルとして認識されなくなる。
# [[rules]] の直前行は空にしないこと。
# ============================================================
# git add の一括追加をブロック
# ============================================================
# 意図しないファイルのコミットを防ぐため、必ずファイル名を指定すること
#
# パターン解説:
# \bgit\b\s+(\S+\s+)*add\b
# → "git add" にマッチ(前後が単語境界)
# → git -C /path add のようにグローバルオプションが挟まっても検出する
# → (\S+\s+)* が 0 個以上の任意トークンを読み飛ばす
# .* → -v や --verbose など任意のフラグを読み飛ばす
# \s → 危険な引数の直前にあるスペース
# ( → 以下のいずれかにマッチ:
# \.(?=\s|$) → "." 単体(./src のような相対パスは除外)
# --all(?=\s|$) → "--all" フラグ
# -A(?=\s|$) → "-A" フラグ("--all" の短縮形)
# -u(?=\s|$) → "-u" フラグ("--update" の短縮形)
# --update(?=\s|$) → "--update" フラグ(追跡済みファイルを全てステージ)
# --no-ignore-removal(?=\s|$) → "--no-ignore-removal" フラグ
# )
# (?=\s|$) は lookahead: 後ろがスペースか行末のときだけマッチ
# → "-Av" のような連結フラグや "./src" の誤検知を防ぐ
[[rules]]
name = "Block git add (bulk)"
reason = """git add . / git add --all / git add -u は禁止されています。
無関係なファイルをコミットしないよう、ファイル名を指定してください。
例: git add src/foo.ts test/foo.test.ts"""
[[rules.conditions]]
field = "tool_name"
pattern = "^Bash$"
[[rules.conditions]]
field = "tool_input.command"
pattern = "\\bgit\\b\\s+(\\S+\\s+)*add\\b.*\\s(\\.(?=\\s|$)|--all(?=\\s|$)|-A(?=\\s|$)|-u(?=\\s|$)|--update(?=\\s|$)|--no-ignore-removal(?=\\s|$))"
# ブロックされるべきケース
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add ." }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add --all" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add -A" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add --no-ignore-removal" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add -v --all" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add -v ." }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add --verbose -A" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git -C /home/user/repo add ." }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git -C /home/user/repo add --all" }
blocked = true
# 許可されるべきケース
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add src/foo.ts" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add src/foo.ts src/bar.ts" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add ./src/foo.ts" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git -C /home/user/repo add src/foo.ts" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add -p" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add -u" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git add --update" }
blocked = true
[[rules.tests]]
tool_name = "Read"
tool_input = { file_path = "/etc/hosts" }
blocked = false
# ============================================================
# git commit の一括ステージをブロック
# ============================================================
# -a / --all は未ステージのファイルも自動的にコミットしてしまうため禁止
# -am "msg" のような連結フラグも検出する
#
# パターン解説:
# \bgit\b\s+(\S+\s+)*commit\b
# → "git commit" にマッチ
# → git -C /path commit のようにグローバルオプションが挟まっても検出する
# .* → 間の任意のフラグを読み飛ばす
# \s → フラグの直前のスペース
# ( → 以下のいずれかにマッチ:
# -[a-zA-Z]*a[a-zA-Z]*(?=\s|$) → -a, -am, -ma, -va 等を含む短縮フラグ
# --all(?=\s|$) → "--all" フラグ
# )
[[rules]]
name = "Block git commit (bulk)"
reason = """git commit -a や git commit --all は禁止されています。
未ステージのファイルを意図せずコミットしないよう、
先に git add でファイルを明示的にステージしてください。"""
[[rules.conditions]]
field = "tool_name"
pattern = "^Bash$"
[[rules.conditions]]
field = "tool_input.command"
pattern = "\\bgit\\b\\s+(\\S+\\s+)*commit\\b.*\\s(-[a-zA-Z]*a[a-zA-Z]*(?=\\s|$)|--all(?=\\s|$))"
# ブロックされるべきケース
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit -a" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit --all" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit -am \"fix: something\"" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit -ma \"fix: something\"" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit -v -a" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git -C /home/user/repo commit -a" }
blocked = true
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git -C /home/user/repo commit -am \"fix: something\"" }
blocked = true
# 許可されるべきケース
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git -C /home/user/repo commit -m \"fix: something\"" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit -m \"fix: something\"" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit --amend" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit -v" }
blocked = false
[[rules.tests]]
tool_name = "Bash"
tool_input = { command = "git commit --no-verify -m \"msg\"" }
blocked = false
# ============================================================
# ルール例(コメントアウト)
# ============================================================
# sudo をブロック
# [[rules]]
# name = "Block sudo"
# reason = "sudo は許可されていません"
#
# [[rules.conditions]]
# field = "tool_name"
# pattern = "^Bash$"
#
# [[rules.conditions]]
# field = "tool_input.command"
# pattern = "\\bsudo\\b"
# .env ファイルへの書き込みをブロック
# [[rules]]
# name = "Protect .env files"
# reason = ".env ファイルへの書き込みは禁止されています"
#
# [[rules.conditions]]
# field = "tool_name"
# pattern = "^(Write|Edit|MultiEdit)$"
#
# [[rules.conditions]]
# field = "tool_input.file_path"
# pattern = "\\.env(\\.|$)"
# パッケージ公開をブロック
# [[rules]]
# name = "Block publish"
# reason = "パッケージの公開は許可されていません"
#
# [[rules.conditions]]
# field = "tool_name"
# pattern = "^Bash$"
#
# [[rules.conditions]]
# field = "tool_input.command"
# pattern = "\\b(npm|pnpm|bun|yarn) publish\\b"
#!/usr/bin/env bun
/**
* Claude Code preToolUse hook
* Reads deny rules from pre-tool-use.toml and blocks matching tool calls.
*
* Exit code 2 = block (reason written to stderr)
* Exit code 0 = allow
*/
declare const Bun: {
stdin: { text(): Promise<string> };
file(path: string): { exists(): Promise<boolean>; text(): Promise<string> };
TOML: { parse(text: string): unknown };
};
declare const process: {
exit(code: number): never;
stderr: { write(s: string): void };
};
export interface Condition {
field: string;
pattern: string;
}
export interface Rule {
name: string;
reason: string;
conditions: Condition[];
}
export interface Config {
rules: Rule[];
}
export interface HookInput {
tool_name: string;
tool_input: Record<string, unknown>;
[key: string]: unknown;
}
/** ドット記法でオブジェクトのフィールドを取得する */
export function getField(obj: unknown, path: string): string | undefined {
const parts = path.split(".");
let current: unknown = obj;
for (const part of parts) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
if (current === undefined || current === null) return undefined;
return String(current);
}
/** 正規表現パターンで値をテストする(無効な regex は false 扱い) */
export function testPattern(value: string, pattern: string): boolean {
try {
return new RegExp(pattern).test(value);
} catch {
process.stderr.write(`[pre-tool-use] 無効な正規表現: ${pattern}\n`);
return false;
}
}
/**
* ルール一覧に対して入力を評価し、最初にマッチしたルールの reason を返す。
* マッチしなければ null を返す。
*/
export function evaluate(input: HookInput, rules: Rule[]): string | null {
for (const rule of rules) {
if (!rule.conditions || !Array.isArray(rule.conditions) || rule.conditions.length === 0) continue;
const allMatch = rule.conditions.every((cond) => {
const value = getField(input, cond.field);
if (value === undefined) return false;
return testPattern(value, cond.pattern);
});
if (allMatch) return rule.reason;
}
return null;
}
async function main(): Promise<void> {
// stdin から JSON を読み取る
const inputText = await Bun.stdin.text();
const input: HookInput = JSON.parse(inputText);
// スクリプトと同じディレクトリの TOML ファイルを読み込む
// import.meta.dir は Bun 固有の拡張で、現在のファイルのディレクトリを返す
// @ts-ignore: import.meta.dir は Bun 固有の拡張
const configPath = `${import.meta.dir}/pre-tool-use.toml`;
const configFile = Bun.file(configPath);
if (!(await configFile.exists())) {
process.exit(0);
}
const configText = await configFile.text();
const config = Bun.TOML.parse(configText) as Config;
if (!config?.rules || !Array.isArray(config.rules)) {
process.exit(0);
}
const reason = evaluate(input, config.rules);
if (reason !== null) {
process.stderr.write(`${reason}\n`);
process.exit(2);
}
process.exit(0);
}
// import.meta.main は Bun 固有の拡張で、直接実行時のみ true になる。
// テスト時は import されるため main() は自動実行されない。
// @ts-ignore: import.meta.main は Bun 固有の拡張
if (import.meta.main) {
main().catch((err: Error) => {
// 予期しないエラーは fail-open(ツールの実行を妨げない)
process.stderr.write(`[pre-tool-use] エラー(fail-open): ${err.message}\n`);
process.exit(0);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment