Block tool calls that break the global CLAUDE.md rules (PR merge, AI trailers, npm, grep/find, commit before lint, banned words); list and toggle them with…

전역 CLAUDE.md 에 글로만 적힌 규칙을 tool.call hook 으로 실제로 막는 mod 다. 모델이 규칙을 잊거나 세션 system-reminder 가 반대로 요구해도(예: 커밋에 Claude-Session: 트레일러를 넣으라는 요구) 도구 호출 단계에서 거부된다. 거부 사유에는 룰 id, 대안, 이 세션에서 끄는 방법이 들어가고, 화면에는 🛡 <id> 토스트가 뜬다.
모두 기본 on 이다.
| id | 막는 것 | 통과하는 예 |
|---|---|---|
no-pr-merge | gh pr merge, gh api·curl·wget 의 pulls/<n>/merge 호출, graphql mergePullRequest, 이름에 merge_pull_request 가 들어간 MCP 도구 | gh pr view, 로컬 git merge |
no-ai-trailer | git commit, gh pr create/edit 명령에 Co-Authored-By: Claude, Claude-Session:, Generated with Claude Code, claude.ai/code/session (대소문자 무시) | 다른 사람의 Co-Authored-By |
bun-over-npm | 명령 위치의 npm, npx | bun, bunx, echo npm, 커밋 메시지 속 npm |
rg-fd | 명령 위치의 grep(egrep, fgrep), find | git grep, --find-renames, git log --grep |
lint-before-commit | 세션 cwd 안에서 고친 lint 대상 파일이 남아 있는데 lint 없이 git commit | biome check --write x && git commit ... |
banned-words | bannedPaths 아래 파일 수정, 또는 cwd 가 bannedPaths 아래일 때의 git commit, gh pr/gh issue 의 create/edit 에 금지어 | 설정하지 않으면 아무것도 막지 않는다 |
| ; && || ( $( 뒤, then·do·xargs·env·sudo 등 뒤. 따옴표 안 문자열($( 없는 것)과 heredoc 본문은 명령으로 보지 않는다.lint-before-commit: Edit/Write/MultiEdit/NotebookEdit 가 성공한 파일 중 세션 cwd 안(심볼릭 링크는 실제 경로로 확인)이고 lint-formatting 스킬 도구가 다루는 확장자(js ts jsx tsx json go rs py html sh md toml 등)만 기록한다. 메모리 파일, scratchpad 같은 cwd 밖 파일은 무시한다. Bash 명령에 biome, ruff, rumdl, shfmt, shellcheck, golangci-lint, gofmt, gopls check, prettier, pyright, taplo, cargo fmt/clippy, make|just|bun run lint|fmt|format 이 들어 있으면 대기 목록을 전부 비운다. lint 결과(성공·실패)는 보지 않는다.이 폴더를 plugin 디렉터리로 로드한다. 둘 중 하나를 쓴다.
~/.claude/settings.json 의 env.CLAUDE_CODE_PLUGIN_DIRS 에 이 폴더 경로를 : 로 이어 붙인다. {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/workspace/myenv/.claude/mods/summary:~/workspace/myenv/.claude/mods/rule-guard"
}
}
claude --plugin-dir <이 폴더> 로 실행한다.Claude Code 2.1.299 에서 확인했다.
/rule-guard: 룰마다 id, 상태(on / off / 설정 안 됨), 이 세션 차단 횟수, 설명을 표로 보여 준다. lint 대기 파일이 있으면 목록도 보여 준다./rule-guard-pane: 같은 상태를 패널로 열고 닫는다. 전체 화면이면 대화 기록 오른쪽에 붙고, 아니면 프롬프트 위에 뜬다. 상태가 바뀌면 패널도 바로 바뀐다. 다른 탭에 가려져 있으면 닫지 않고 앞으로 꺼낸다. 출력은 남기지 않는다. Claude 를 시작하면 패널이 바로 열린다(터미널이 좁으면 대기, /reload-plugins 때는 다시 열지 않음)./rule-guard off <id>, /rule-guard on <id>: 이 세션 동안만 끄고 켠다. 잘못된 id 면 사용 가능한 id 를 알려 준다./rule-guard help: 사용법. 단축키가 없으면 설정 방법도 보여 준다.모델은 슬래시 명령을 실행할 수 없어서 룰을 끄는 건 사용자만 할 수 있다. 예를 들어 호환성 문제로 npm 이 꼭 필요하면 /rule-guard off bun-over-npm 으로 끈다. 끈 상태와 차단 횟수는 /reload-plugins 를 해도 세션 동안 유지되고, 새 세션에서는 다시 모두 켜진다.
keybindings.json 의 command:<이름> action 은 그 슬래시 명령을 입력한 것처럼 실행한다. action 에는 인자를 넣을 수 없어서 인자 없는 /rule-guard-pane 을 따로 둔다. ~/.claude/keybindings.json 에 다음을 넣는다. 파일이 이미 있으면 덮어쓰지 말고 "Chat" 블록의 bindings 에 한 줄만 추가한다(없으면 블록을 추가한다).
{
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+x r": "command:rule-guard-pane"
}
}
]
}
help·명령 목록 안내가 그 키를 따른다.ctrl+x r rule-guard 가 보인다.pane:previous/pane:next 를 "Global" 블록에 묶는다(예: "ctrl+<": "pane:previous", "ctrl+>": "pane:next", 터미널에 따라 ctrl+shift+,/ctrl+shift+. 도 함께). 묶으면 패널 하단에 tabs 로 보인다.bannedWords(쉼표 구분 금지어)와 bannedPaths(쉼표 구분 경로 prefix, ~ 사용 가능)를 둘 다 넣어야 동작한다. 하나라도 비어 있으면 /rule-guard 에 "설정 안 됨" 으로 나오고 아무것도 막지 않는다. /config 메뉴의 rule-guard 항목에서 넣거나 ~/.claude/settings.json 에 직접 넣는다. 키는 plugin 이름 rule-guard 다(엔진은 rule-guard@inline 도 읽는다).
{
"pluginConfigs": {
"rule-guard": {
"options": {
"bannedWords": "secretword,another-secret",
"bannedPaths": "~/workspace/public-repo"
}
}
}
}
금지어는 대소문자를 무시하고 부분 문자열로 찾는다. 경로는 적힌 경로와 심볼릭 링크를 따라간 실제 경로를 모두 확인한다.
claude plugin validate .
claude plugin test .
biome check --write hooks/register.tsx tests/rule-guard.test.tsx types/index.d.ts
타입 체크는 mod 가 한 번 로드돼 .claude-plugin/types/ 가 생긴 뒤 bunx -p typescript tsc -p . 로 한다.
hooks/register.tsx 655 lines1import type { EngineInterface, Register, ToolCallInput } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4const off = atom({ plugin: "rule-guard", key: "off" } as const, []);
5
6const blocked = atom({ plugin: "rule-guard", key: "blocked" } as const, {});
7
8// files edited since the last lint/format run
9const unlinted = atom({ plugin: "rule-guard", key: "pending" } as const, []);
10
11const chord = atom({ plugin: "rule-guard", key: "chord" } as const, "");
12
13const tabs = atom({ plugin: "rule-guard", key: "tabs" } as const, "");
14
15// a reload raises session.start again, so the pane opens on its own only once
16const autoOpened = atom(
17 { plugin: "rule-guard", key: "autoOpened" } as const,
18 false,
19);
20
21const PANE = "rule-guard";
22
23// keybindings.json runs it as "command:rule-guard-pane", which takes no arguments
24const PANE_COMMAND = "rule-guard-pane";
25
26const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
27
28// a name where the shell runs a command: line start, after ; & | ( { ` $(,
29// a keyword, or a wrapper, with flags and VAR=x before it
30const at = (name: string) =>
31 new RegExp(
32 String.raw`(?:^|[\n;&|({\x60]|\$\(|\b(?:then|do|else|xargs|env|sudo|time|exec|command|nohup)\b)\s*(?:-\S+\s+|\w+=\S*\s+)*(?:` +
33 name +
34 String.raw`)(?=$|[\s;&|)\x60])`,
35 );
36
37const GIT_COMMIT = at(
38 String.raw`git(?:\s+-[cC]\s+\S+|\s+--[\w-]+(?:=\S+)?)*\s+commit`,
39);
40
41const GH_PR_WRITE = at(String.raw`gh\s+pr\s+(?:create|edit)`);
42
43const PUBLISH = at(
44 String.raw`git(?:\s+-[cC]\s+\S+|\s+--[\w-]+(?:=\S+)?)*\s+commit|gh\s+(?:pr|issue)\s+(?:create|edit)`,
45);
46
47const GH_MERGE = at(String.raw`gh\s+pr\s+merge`);
48
49const API = at(String.raw`gh\s+api|curl|wget`);
50
51const MERGE_API = /\bpulls\/[^\s/]+\/merge\b|\bmergePullRequest\b/;
52
53const NPM = at("npm|npx");
54
55const GREP_FIND = at("[ef]?grep|find");
56
57const TRAILER =
58 /co-authored-by:\s*claude|claude-session:|generated with \[?claude code|claude\.ai\/code\/session/i;
59
60const LINT =
61 /\b(?:biome|ruff|rumdl|shfmt|shellcheck|golangci-lint|gofmt|gopls\s+check|prettier|pyright|taplo|cargo\s+(?:fmt|clippy)|(?:make|just|bun\s+run)\s+(?:lint|fmt|format))\b/i;
62
63// what the lint-formatting skill's tools cover
64const LINTED = /\.(?:[cm]?[jt]sx?|jsonc?|go|rs|pyi?|html?|sh|bash|md|toml)$/;
65
66// heredoc bodies and quoted text that runs nothing, taken out
67export const shell = (cmd: string) =>
68 cmd
69 .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\n|$)/g, "<<$2\n")
70 .replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (s) =>
71 s.startsWith("'") || !/[$`]/.test(s) ? `${s[0]}${s[0]}` : s,
72 );
73
74const fields = (e: object) =>
75 e as {
76 file_path?: unknown;
77 notebook_path?: unknown;
78 new_string?: unknown;
79 content?: unknown;
80 new_source?: unknown;
81 edits?: { new_string?: unknown }[];
82 };
83
84const pathOf = (e: object) => {
85 const { file_path, notebook_path } = fields(e);
86 const path = file_path ?? notebook_path;
87 return typeof path === "string" ? path : undefined;
88};
89
90const newText = (e: object) => {
91 const { new_string, content, new_source, edits = [] } = fields(e);
92 return [new_string, content, new_source, ...edits.map((x) => x.new_string)]
93 .filter((s) => typeof s === "string")
94 .join("\n");
95};
96
97// the path as spelled and where it really lands (a new file: its folder's)
98const places = async ($: EngineInterface, path: string) => {
99 const cut = path.lastIndexOf("/");
100 const own = await $.fs.stat(path, { resolve: true }).catch(() => undefined);
101 const dir = own
102 ? undefined
103 : await $.fs
104 .stat(path.slice(0, cut) || "/", { resolve: true })
105 .catch(() => undefined);
106 const real =
107 own?.realPath ??
108 (dir?.realPath && `${dir.realPath.replace(/\/$/, "")}${path.slice(cut)}`);
109 return real && real !== path ? [path, real] : [path];
110};
111
112const under = (paths: string[], roots: string[]) =>
113 paths.some((p) =>
114 roots.some((r) => p === r || p.startsWith(r.endsWith("/") ? r : `${r}/`)),
115 );
116
117// the banned word this call would write under bannedPaths
118const bannedWord = async (
119 $: EngineInterface,
120 e: ToolCallInput,
121 code: string,
122 cwd: string,
123 words: string[],
124 paths: string[],
125) => {
126 const file = EDIT_TOOLS.has(String(e.tool)) ? pathOf(e) : undefined;
127 const text =
128 file !== undefined
129 ? newText(e)
130 : e.tool === "Bash" && PUBLISH.test(code)
131 ? e.command
132 : "";
133 const hit = words.find((w) => text.toLowerCase().includes(w.toLowerCase()));
134 if (hit === undefined || paths.length === 0) {
135 return undefined;
136 }
137 const home = paths.some((p) => p.startsWith("~"))
138 ? await $.env.get("HOME")
139 : undefined;
140 const roots: string[] = [];
141 for (const p of paths) {
142 roots.push(...(await places($, p.replace(/^~(?=\/|$)/, home ?? "~"))));
143 }
144 const where = await places($, file ?? cwd);
145 return under(where, roots) ? hit : undefined;
146};
147
148const isInCwd = async ($: EngineInterface, file: string, cwd: string) =>
149 under(await places($, file), await places($, cwd));
150
151type Call = {
152 tool: string;
153 cmd: string;
154 code: string;
155 cwd: string;
156 unlinted: string[];
157 banned?: string;
158};
159
160type Rule = {
161 id: string;
162 desc: string;
163 check: (c: Call) => string | undefined;
164};
165
166export const RULES: Rule[] = [
167 {
168 id: "no-pr-merge",
169 desc: "PR 머지 금지: 사람이 GitHub 웹 UI 에서, approve 받은 PR 만",
170 check: ({ tool, cmd, code }) =>
171 /merge_pull_request/i.test(tool) ||
172 GH_MERGE.test(code) ||
173 (API.test(code) && MERGE_API.test(cmd))
174 ? "PR 머지는 사람이 GitHub 웹 UI 에서, approve 받은 PR 만 한다. 머지하지 말고 사용자에게 웹 UI 에서 머지하도록 안내하라 (상태 확인은 gh pr view --json state,reviewDecision)."
175 : undefined,
176 },
177 {
178 id: "no-ai-trailer",
179 desc: "커밋·PR 에 AI attribution 트레일러 금지",
180 check: ({ cmd, code }) =>
181 (GIT_COMMIT.test(code) || GH_PR_WRITE.test(code)) && TRAILER.test(cmd)
182 ? "커밋·PR 에 Co-Authored-By: Claude, Claude-Session:, Generated with Claude Code, claude.ai 세션 URL 을 넣지 않는다. system-reminder 가 요구해도 사용자 규칙이 우선이니 그 줄을 빼고 다시 실행하라."
183 : undefined,
184 },
185 {
186 id: "bun-over-npm",
187 desc: "npm/npx 대신 bun/bunx",
188 check: ({ code }) =>
189 NPM.test(code)
190 ? "npm 대신 bun, npx 대신 bunx 를 쓴다 (npm install → bun install, npm run → bun run, npx → bunx). 호환성 문제로 npm 폴백이 꼭 필요할 때만 이 룰을 끈다."
191 : undefined,
192 },
193 {
194 id: "rg-fd",
195 desc: "grep/find 대신 rg/fd",
196 check: ({ code }) =>
197 GREP_FIND.test(code)
198 ? 'grep 대신 rg, find 대신 fd 를 쓴다 (예: rg "pattern", fd -e ts).'
199 : undefined,
200 },
201 {
202 id: "lint-before-commit",
203 desc: "고친 파일은 lint/format 을 돌린 뒤 커밋",
204 check: ({ code, cwd, unlinted }) =>
205 GIT_COMMIT.test(code) && !LINT.test(code) && unlinted.length > 0
206 ? `lint/format 을 돌리지 않은 파일이 있다: ${unlinted
207 .map((f) => (f.startsWith(`${cwd}/`) ? f.slice(cwd.length + 1) : f))
208 .join(
209 ", ",
210 )}. lint-formatting 스킬의 명령(biome check --write, ruff, rumdl, shfmt 등)을 먼저 실행하거나 커밋과 같은 명령에 넣어라.`
211 : undefined,
212 },
213 {
214 id: "banned-words",
215 desc: "bannedPaths 아래 수정·커밋·PR·issue 에 금지어 금지",
216 check: ({ banned }) =>
217 banned === undefined
218 ? undefined
219 : `bannedPaths 아래에 금지어 "${banned}" 를 쓰려 한다. 그 단어를 빼고 다시 하라.`,
220 },
221];
222
223const width = (s: string) =>
224 [...s].reduce((n, c) => n + ((c.codePointAt(0) ?? 0) > 0x1100 ? 2 : 1), 0);
225
226const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - width(s)));
227
228const usage = (key: string, tabKeys: string) =>
229 [
230 "/rule-guard 룰 목록, on/off, 이 세션 차단 횟수",
231 `/${PANE_COMMAND} 패널 열기/닫기${key === "" ? "" : ` (${key})`}`,
232 ...(tabKeys === "" ? [] : [`${pad(tabKeys, 24)}이전/다음 패널 탭`]),
233 "/rule-guard off <id> 이 세션 동안 룰 끄기",
234 "/rule-guard on <id> 다시 켜기",
235 "/rule-guard help 이 도움말",
236 "",
237 `id: ${RULES.map((r) => r.id).join(", ")}`,
238 "끄기는 이 세션에만 적용된다. 모델은 슬래시 명령을 실행할 수 없어 사용자만 바꿀 수 있다.",
239 "banned-words 는 settings.json 의 pluginConfigs 나 /config 에서 rule-guard 의 bannedWords, bannedPaths(쉼표 구분)를 넣어야 동작한다.",
240 ...(key !== "" && tabKeys !== ""
241 ? []
242 : [
243 "",
244 "단축키는 mod 가 등록하지 않는다. ~/.claude/keybindings.json 에 직접 추가한다(기존 블록에 합친다, README 참고):",
245 ...(key === ""
246 ? [
247 ` "Chat": "ctrl+x r": "command:${PANE_COMMAND}" 패널 열기/닫기`,
248 ]
249 : []),
250 ...(tabKeys === ""
251 ? [
252 ' "Global": "ctrl+<": "pane:previous", "ctrl+>": "pane:next" 이전/다음 패널 탭',
253 ' 터미널에 따라 "ctrl+shift+," / "ctrl+shift+." 도 함께 묶는다',
254 ]
255 : []),
256 "다른 키에 묶어도 되고, 안내는 묶은 키를 따른다.",
257 ]),
258 ].join("\n");
259
260const boundKeys = async ($: EngineInterface) => {
261 try {
262 const { bindings = [] } = JSON.parse(
263 await $.fs.read(`${await $.env.get("HOME")}/.claude/keybindings.json`),
264 ) as {
265 bindings?: { context?: string; bindings?: Record<string, unknown> }[];
266 };
267 return bindings.flatMap((b) => Object.entries(b.bindings ?? {}));
268 } catch {
269 return [];
270 }
271};
272
273const syncChord = async ($: EngineInterface) => {
274 const bound = await boundKeys($);
275 const keyOf = (action: string) =>
276 bound.find(([, a]) => a === action)?.[0] ?? "";
277 const key = keyOf(`command:${PANE_COMMAND}`);
278 if ((await read($, chord)) !== key) {
279 await update($, chord, () => key);
280 }
281 const tabKeys = [keyOf("pane:previous"), keyOf("pane:next")]
282 .filter(Boolean)
283 .join(" ");
284 if ((await read($, tabs)) !== tabKeys) {
285 await update($, tabs, () => tabKeys);
286 }
287 return key;
288};
289
290// Shown: close. Open behind another pane's tab: reopen it, since opening an
291// open id only retitles it while a new pane becomes the shown tab.
292const toggle = async ($: EngineInterface) => {
293 const pane = (await $.ui.panes()).find((p) => p.id === PANE);
294 if (pane !== undefined) {
295 await $.ui.close({ id: PANE });
296 }
297 if (!pane?.isShown) {
298 await $.ui.open({ id: PANE, title: PANE, columns: 44 });
299 }
300};
301
302type Span = [string, Record<string, unknown>?];
303
304// the summary mod's palette, so the two panes look alike
305const C = {
306 subtext: "#a6adc8",
307 overlay: "#7f849c",
308 surface1: "#45475a",
309 surface0: "#313244",
310 mauve: "#cba6f7",
311 lavender: "#b4befe",
312 blue: "#89b4fa",
313 green: "#a6e3a1",
314 yellow: "#f9e2af",
315 red: "#f38ba8",
316};
317
318const MARK: Record<string, [string, string]> = {
319 on: ["●", C.green],
320 off: ["○", C.yellow],
321 "설정 안 됨": ["·", C.overlay],
322};
323
324const STATUS: Record<string, string> = {
325 on: "success",
326 off: "warning",
327 "설정 안 됨": "inactive",
328};
329
330const countStyle = (n: number) =>
331 n === 0 ? { dimColor: true } : { color: "error", bold: true };
332
333const ROW = /^(\S+)(\s+)(on|off|설정 안 됨)(\s+)(\d+)(\s+)(.*)$/;
334
335const paint = (line: string): Span[] => {
336 const row = ROW.exec(line);
337 if (row) {
338 const [, id = "", s1 = "", st = "", s2 = "", n = "", s3 = "", desc = ""] =
339 row;
340 return [
341 [id, { color: "suggestion", bold: true }],
342 [s1],
343 [st, { color: STATUS[st] }],
344 [s2],
345 [n, countStyle(Number(n))],
346 [s3],
347 [desc],
348 ];
349 }
350 if (/^id\s+상태/.test(line)) return [[line, { underline: true }]];
351 if (line.startsWith("lint 대기")) return [[line, { color: "warning" }]];
352 if (line.includes("알 수 없는")) return [[line, { color: "error" }]];
353 const ack = /^(.*이 세션 동안 )(on|off)$/.exec(line);
354 if (ack) {
355 const [, pre = "", st = ""] = ack;
356 return [[pre], [st, { color: STATUS[st], bold: true }]];
357 }
358 const use = /^(\/[\w-]+(?: \w+(?: <id>)?)?)( {2,}.*)$/.exec(line);
359 if (use) {
360 const [, cmd = "", rest = ""] = use;
361 return [[cmd, { color: "suggestion" }], [rest]];
362 }
363 return [[line, { dimColor: true }]];
364};
365
366const statusOf = async ($: EngineInterface, isSet: boolean) => {
367 const disabled = await read($, off);
368 const counts = await read($, blocked);
369 return RULES.map((r) => ({
370 id: r.id,
371 desc: r.desc,
372 status:
373 r.id === "banned-words" && !isSet
374 ? "설정 안 됨"
375 : disabled.includes(r.id)
376 ? "off"
377 : "on",
378 count: counts[r.id] ?? 0,
379 }));
380};
381
382const table = async ($: EngineInterface, isSet: boolean) => {
383 const files = await read($, unlinted);
384 const rows = [
385 ["id", "상태", "차단", "설명"],
386 ...(await statusOf($, isSet)).map((r) => [
387 r.id,
388 r.status,
389 String(r.count),
390 r.desc,
391 ]),
392 ];
393 const widths = [0, 1, 2].map((i) =>
394 Math.max(...rows.map((row) => width(row[i] ?? ""))),
395 );
396 return [
397 "",
398 ...rows.map((row) =>
399 row
400 .map((cell, i) => (i < 3 ? pad(cell, widths[i] ?? 0) : cell))
401 .join(" "),
402 ),
403 ...(files.length > 0
404 ? ["", `lint 대기 ${files.length}개: ${files.join(", ")}`]
405 : []),
406 "",
407 "/rule-guard off <id> · /rule-guard on <id> 로 이 세션 동안 끄고 켠다 · /rule-guard help",
408 ].join("\n");
409};
410
411export const register: Register = (on, options) => {
412 const list = (v: unknown) =>
413 String(v ?? "")
414 .split(",")
415 .map((s) => s.trim())
416 .filter(Boolean);
417 const words = list(options.bannedWords);
418 const paths = list(options.bannedPaths);
419 const isSet = words.length > 0 && paths.length > 0;
420
421 on("session.start", async ($, e, next) => {
422 const key = await syncChord($);
423 await $.command.register({
424 name: "rule-guard",
425 description:
426 "rule-guard 룰 목록·상태·차단 횟수, 이 세션 on/off · help: 사용법",
427 argumentHint: "[off <id> | on <id> | help]",
428 immediate: true,
429 });
430 await $.command.register({
431 name: PANE_COMMAND,
432 description: `rule-guard 패널 열기/닫기${key === "" ? "" : ` (${key})`}`,
433 immediate: true,
434 });
435 if (e.isInteractive && !(await read($, autoOpened))) {
436 await update($, autoOpened, () => true);
437 await $.ui.open({ id: PANE, title: PANE, columns: 44 });
438 }
439 return next(e);
440 }).catch((_, e, next) => next(e));
441
442 // no text, so the shortcut leaves nothing in the transcript
443 on("command.run", { command: PANE_COMMAND }, async ($) => {
444 await syncChord($);
445 await toggle($);
446 return {};
447 });
448
449 on("command.run", { command: "rule-guard" }, async ($, e) => {
450 const [verb = "", id = ""] = e.args.trim().split(/\s+/);
451 const key = await syncChord($);
452 if (verb === "") {
453 return { text: await table($, isSet) };
454 }
455 if (verb === "help") {
456 return { text: `\n${usage(key, await read($, tabs))}` };
457 }
458 if (verb !== "on" && verb !== "off") {
459 return {
460 text: `알 수 없는 인자: ${e.args.trim()}\n\n${usage(key, await read($, tabs))}`,
461 };
462 }
463 if (!RULES.some((r) => r.id === id)) {
464 return {
465 text: `알 수 없는 룰 id: ${id || "(없음)"}\n사용 가능한 id: ${RULES.map((r) => r.id).join(", ")}`,
466 };
467 }
468 await update($, off, (ids) =>
469 verb === "off"
470 ? [...ids.filter((x) => x !== id), id]
471 : ids.filter((x) => x !== id),
472 );
473 return { text: `${id}: 이 세션 동안 ${verb}` };
474 });
475
476 on(
477 "ui.render",
478 { component: "CommandOutput", props: { command: "rule-guard" } },
479 async ($, e) => {
480 const { Box, Text } = $.ui.resolve(e);
481 return (
482 <Box flexDirection="row">
483 <Text dimColor>{" ⎿ "}</Text>
484 <Box flexDirection="column">
485 {e.props.text.split("\n").map((line) => (
486 <Text>
487 {line === ""
488 ? " "
489 : paint(line).map(([s, p]) => <Text {...p}>{s}</Text>)}
490 </Text>
491 ))}
492 </Box>
493 </Box>
494 );
495 },
496 );
497
498 on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
499 const key = await read($, chord);
500 if (key === "") {
501 return next(e);
502 }
503 const tail = [e.props.tail, `${key} ${PANE}`].filter(Boolean).join(" · ");
504 return next({ ...e, props: { ...e.props, tail } });
505 });
506
507 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
508 const { Box, Text } = $.ui.resolve(e);
509 const rules = await statusOf($, isSet);
510 const files = await read($, unlinted);
511 const key = await read($, chord);
512 const tabKeys = await read($, tabs);
513 const columns = Math.max(30, e.props.bodyColumns);
514 const idWidth = Math.max(...rules.map((r) => width(r.id)));
515 const count = (s: string) => rules.filter((r) => r.status === s).length;
516 const total = rules.reduce((n, r) => n + r.count, 0);
517 const keycap = (k: string) => (
518 <Text backgroundColor={C.surface0} color={C.lavender}>{` ${k} `}</Text>
519 );
520 return (
521 <Box flexDirection="column" width={columns}>
522 <Box paddingX={1} justifyContent="space-between">
523 <Text bold color={C.mauve}>
524 RULE-GUARD
525 </Text>
526 <Text color={C.subtext}>
527 {`${count("on")} on · ${count("off")} off · 차단 ${total}`}
528 </Text>
529 </Box>
530 <Box paddingX={1}>
531 <Text color={C.surface1}>{"━".repeat(columns - 2)}</Text>
532 </Box>
533 <Box
534 flexDirection="column"
535 borderStyle="round"
536 borderColor={C.surface1}
537 paddingX={1}
538 >
539 <Text bold color={C.lavender}>
540 rules
541 </Text>
542 {rules.map((r) => (
543 <Box key={r.id} flexDirection="column">
544 <Text wrap="truncate-end">
545 <Text color={MARK[r.status]?.[1]}>{MARK[r.status]?.[0]}</Text>{" "}
546 <Text bold color={C.blue}>
547 {pad(r.id, idWidth)}
548 </Text>
549 {" "}
550 <Text color={MARK[r.status]?.[1]}>{pad(r.status, 10)}</Text>
551 {" "}
552 <Text
553 bold={r.count > 0}
554 color={r.count > 0 ? C.red : C.overlay}
555 >
556 {String(r.count)}
557 </Text>
558 </Text>
559 <Box paddingLeft={2}>
560 <Text color={C.subtext}>{r.desc}</Text>
561 </Box>
562 </Box>
563 ))}
564 </Box>
565 {files.length > 0 && (
566 <Box
567 flexDirection="column"
568 borderStyle="round"
569 borderColor={C.yellow}
570 paddingX={1}
571 >
572 <Text bold color={C.yellow}>
573 {`lint 대기 ${files.length}`}
574 </Text>
575 {files.map((f) => (
576 <Text color={C.subtext} wrap="truncate-start">
577 {f}
578 </Text>
579 ))}
580 </Box>
581 )}
582 <Box paddingX={1} columnGap={2} flexWrap="wrap">
583 {key !== "" && (
584 <Text>
585 {keycap(key)}
586 <Text color={C.subtext}> close</Text>
587 </Text>
588 )}
589 {tabKeys !== "" && (
590 <Text>
591 {keycap(tabKeys)}
592 <Text color={C.subtext}> tabs</Text>
593 </Text>
594 )}
595 {keycap("/rule-guard off|on <id>")}
596 </Box>
597 </Box>
598 );
599 });
600
601 on("tool.call", async ($, e, next) => {
602 const tool = String(e.tool);
603 const cmd = e.tool === "Bash" ? e.command : "";
604 const code = shell(cmd);
605 const disabled = await read($, off);
606 const cwd = await $.session.cwd();
607 const call: Call = {
608 tool,
609 cmd,
610 code,
611 cwd,
612 unlinted: await read($, unlinted),
613 banned: disabled.includes("banned-words")
614 ? undefined
615 : await bannedWord($, e, code, cwd, words, paths),
616 };
617 for (const rule of RULES) {
618 const why = disabled.includes(rule.id) ? undefined : rule.check(call);
619 if (why !== undefined) {
620 await update($, blocked, (n) => ({
621 ...n,
622 [rule.id]: (n[rule.id] ?? 0) + 1,
623 }));
624 $.ui.toast(`🛡 ${rule.id}`);
625 return {
626 deny: `rule-guard ${rule.id}: ${why} 이 세션에서 이 룰을 끄려면 사용자에게 /rule-guard off ${rule.id} 를 요청하라.`,
627 };
628 }
629 }
630 const ran = await next(e);
631 if (ran.deny !== undefined) {
632 return ran;
633 }
634 if (LINT.test(code) && call.unlinted.length > 0) {
635 await update($, unlinted, () => []);
636 }
637 const file = EDIT_TOOLS.has(tool) && !ran.isError ? pathOf(e) : undefined;
638 if (
639 file !== undefined &&
640 LINTED.test(file) &&
641 !call.unlinted.includes(file) &&
642 (await isInCwd($, file, cwd))
643 ) {
644 await update($, unlinted, (files) => [...files, file]);
645 }
646 return ran;
647 }).catch((_, e, next) =>
648 next.called
649 ? next(e)
650 : {
651 deny: `rule-guard: 룰 검사가 실패해 막았다 (${next.error.message ?? next.error.kind}).`,
652 },
653 );
654};
655types/index.d.ts 15 lines1export type BlockCounts = Record<string, number>;
2
3declare module "claude-code" {
4 interface PluginState {
5 "rule-guard": {
6 off: string[];
7 blocked: BlockCounts;
8 pending: string[];
9 chord: string;
10 tabs: string;
11 autoOpened: boolean;
12 };
13 }
14}
15