Created
March 27, 2026 03:05
-
-
Save f440/bcabbfd8935284fb60a5b5e9dea37a5a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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