Bouncer for Claude Code: enforce condition-based markdown guardrail rules on tool calls — deny or annotate any call whose content matches, instead of…

A markdown rule names a recognizable mistake as a regex; when a tool call introduces matching content, the mod denies the call with the rule body as the error (default), or lets it run and appends the body as model-only result context (interruptMode: never).
Inspired by oh-my-pi's Time-Traveling Stream Rules (TTSR).
Function-hook mods are early access and only load with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment. Without it, Claude Code ignores the hooks and no rules are enforced.
claude plugin marketplace add ffalor/ffalor-plugins
claude plugin install bouncer@ffalor-plugins
Or session-only: claude --plugin-dir <checkout>/ffalor-plugins/plugins/bouncer.
When the agent keeps repeating a mistake, run /omfg with a short complaint (e.g. /omfg keeps using any in TypeScript). It drafts a rule that would have caught it, asks where to keep it, and saves the file. Rules take effect in new sessions.
Rules are ordinary Claude rules files — the mod reuses the native path and detects guardrails by frontmatter. Any .claude/rules/*.md file carrying a condition: is also a Bouncer rule; files without one are plain instruction files and are ignored here.
<project>/.claude/rules/*.md (also loaded as native context)<project>/.claude/bouncer-rules/*.md (Bouncer-only; never loaded as context)~/.claude/rules/*.md~/.claude/bouncer-rules/*.mdWhen two files share a rule name, the first location in this list wins and the other is reported as shadowed. The rule name is the filename (<name>.md); there is no name: frontmatter field.
Use bouncer-rules/ for guardrails you don't want injected as always-on context. Start a new session after adding or changing a rule (per-session load).
description: one-line summary, shown in diagnostics.condition: JavaScript regex, or list of regexes (alternatives are ORed). Must match the offending tool-call content. Leading (?i)/(?m)/(?s) flags are supported. A bare file glob (e.g. *.rs) matches any edit/write to that glob.scope: which tools the rule watches. A comma-separated string or YAML list of tokens: tool (every tool), tool:<name>, tool:<name>(<glob>), or a bare tool name such as bash. Omit it to watch all tools.globs: optional extra path gate. Native paths: doubles as the gate when no globs: is given.interruptMode: always denies the call with the rule body as the error; never lets it run and appends the body as model-only result context. Falls back to the global interruptMode when unset.repeatMode: once fires this rule once per session; after-gap re-arms after repeatGap user turns. Falls back to the global repeatMode when unset or unknown.repeatGap: completed user turns before this after-gap rule may fire again. Falls back to the global repeatGap when unset or invalid.enabled: false: disables the rule.Session defaults, set in plugin userConfig:
enabled (default true): master switch. false disables all rule matching.interruptMode (default always): enforcement for rules without their own interruptMode. always denies the call; never advises.repeatMode (default once): once fires each rule once per session; after-gap re-arms after repeatGap completed user turns. A rule's own repeatMode overrides this. Suppression is per session (survives resume) and resets when a rule's content changes.repeatGap (default 10): completed user turns before an after-gap rule may fire again. A rule's own repeatGap overrides this.disabledRules (default ""): comma-separated rule names to skip.Adapted from oh-my-pi's ts-no-tiny-functions builtin rule (MIT).
---
description: "Do not extract 1-2 line functions that only wrap an expression — inline them"
condition: "(?m)\\{\\s*return [^;{}\\n]+;?\\s*\\}|\\b(?:const|let|var)\\s+[\\w$]+\\s*=\\s*(\\([^)]*\\)|[a-zA-Z_$][\\w$]*)\\s*=>\\s*[^{\\n]+$"
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
interruptMode: never
---
Inline functions whose whole body: one expression or `return`, unless name creates a durable contract.
## Why
- One-line wrappers: no real behavior.
- Readers: jump to verify trivial code.
- Signature: freezes shape too early.
- Inline expressions: better search and type flow.
## Avoid
// Bad — pure rename, no behavior added. function isEmpty(value: string): boolean { return value.length === 0; }
const getDisplayName = (user: User) => user.profile.displayName;
## Use
if (name.length === 0) { ... } const displayName = user.profile.displayName;
## Allowed tiny functions
- Three or more call sites need lockstep behavior.
- Exported name: stable domain concept.
- Type guard preserves narrowing.
- Public API, test seam, or DI boundary needs indirection.
If none apply, inline it.
claude plugin validate ./plugins/bouncer --strict --jsonhooks/register.ts 213 lines1// Tool-call guard: deny or annotate risky tool calls before they run.
2//
3// Every $.noun.event(...) call is spelled out at the call site below;
4// helpers only ever receive plain data, never $.
5import type { On, PluginOptions } from "claude-code";
6
7import { normalizeConfig, toolShouldInterrupt } from "./bouncer-config";
8import type { BouncerConfig } from "./bouncer-config";
9import { compileRules, ruleNameOf } from "./bouncer-rules";
10import type { CompiledRule, RuleFile } from "./bouncer-rules";
11import { matchToolRules, renderCorrection, toolCandidates } from "./bouncer-match";
12import {
13 STORE_KEY,
14 asStoreDoc,
15 marksForSession,
16 recordInMemory,
17 selectEligible,
18 withRecorded,
19} from "./bouncer-state";
20import type { LoopMarks } from "./bouncer-state";
21
22interface RulesCache {
23 key: string;
24 rules: CompiledRule[];
25}
26
27function providersFor(
28 cwd: string,
29 home: string | undefined,
30): { dir: string; label: string; native: boolean }[] {
31 const providers: { dir: string; label: string; native: boolean }[] = [];
32 if (cwd) {
33 providers.push({ dir: cwd.replace(/\/+$/, "") + "/.claude/rules", label: "project .claude/rules", native: true });
34 providers.push({ dir: cwd.replace(/\/+$/, "") + "/.claude/bouncer-rules", label: "project .claude/bouncer-rules", native: false });
35 }
36 if (home) {
37 providers.push({ dir: home.replace(/\/+$/, "") + "/.claude/rules", label: "user ~/.claude/rules", native: true });
38 providers.push({ dir: home.replace(/\/+$/, "") + "/.claude/bouncer-rules", label: "user ~/.claude/bouncer-rules", native: false });
39 }
40 return providers;
41}
42
43
44export function register(on: On, options: PluginOptions) {
45 const config: BouncerConfig = normalizeConfig(options);
46 let cache: RulesCache | null = null;
47 let memory: Record<string, LoopMarks> = {};
48 let chain: Promise<void> = Promise.resolve();
49 let loggedForKey: string | null = null;
50
51 on("tool.call", async ($, e, next) => {
52 if (!config.enabled) return next(e);
53 const tool = (e as unknown as { tool: string }).tool;
54 if (typeof tool !== "string" || tool.length === 0) return next(e);
55 const agentId = (e as unknown as { agentId?: string }).agentId;
56
57 let sessionId = "";
58 try {
59 sessionId = await $.session.id();
60 } catch {
61 sessionId = "";
62 }
63 let cwd = "";
64 try {
65 cwd = await $.session.cwd();
66 } catch {
67 cwd = "";
68 }
69 const cacheKey = sessionId + "\n" + cwd;
70 if (cache === null || cache.key !== cacheKey) {
71 let home: string | undefined;
72 try {
73 const h =
74 (await $.env.get("HOME").catch(() => undefined)) ??
75 (await $.env.get("USERPROFILE").catch(() => undefined));
76 home = typeof h === "string" && h.length > 0 ? h : undefined;
77 } catch {
78 home = undefined;
79 }
80 const providers = providersFor(cwd, home);
81 const files: RuleFile[] = [];
82 for (const provider of providers) {
83 let names: string[];
84 try {
85 const entries = await $.fs.list(provider.dir);
86 names = entries
87 .map((entry) => entry.name)
88 .filter((n) => ruleNameOf(n) !== null)
89 .sort();
90 } catch {
91 continue;
92 }
93 for (const filename of names) {
94 const path = provider.dir + "/" + filename;
95 let text: string;
96 try {
97 text = (await $.fs.read(path)) as string;
98 } catch {
99 continue;
100 }
101 const name = ruleNameOf(filename);
102 if (name !== null) {
103 files.push({ name, text, label: provider.label, file: path, native: provider.native });
104 }
105 }
106 }
107 const found = compileRules(files, config.disabledRules);
108 cache = { key: cacheKey, rules: found.rules };
109 if (loggedForKey !== cacheKey) {
110 loggedForKey = cacheKey;
111 for (const line of found.diagnostics.slice(0, 8)) {
112 try {
113 $.ui.log(line);
114 } catch {
115 break;
116 }
117 }
118 }
119 }
120 const rules = cache.rules;
121 if (rules.length === 0) return next(e);
122
123 const sid = sessionId;
124 await chain
125 .then(async () => {
126 if (memory[sid]) return;
127 let raw: unknown;
128 try {
129 raw = await $.store.get(STORE_KEY);
130 } catch {
131 return;
132 }
133 memory[sid] = marksForSession(asStoreDoc(raw), sid);
134 })
135 .catch(() => undefined);
136 let turns = 0;
137 try {
138 turns = await $.session.turns();
139 } catch {
140 turns = 0;
141 }
142 const info = { sessionId: sid, loop: agentId ?? "main", turns };
143
144 const rawArgs = e as unknown as Record<string, unknown>;
145 const args: Record<string, unknown> = {};
146 for (const [k, v] of Object.entries(rawArgs)) {
147 if (k !== "tool" && k !== "tool_use_id" && k !== "agentId") args[k] = v;
148 }
149 const candidates = toolCandidates(tool, args);
150 if (candidates.length === 0) return next(e);
151 const matched = matchToolRules(rules, tool, candidates);
152 if (matched.length === 0) return next(e);
153 const eligible = selectEligible(matched, memory[sid] ?? {}, info, config);
154 if (eligible.length === 0) return next(e);
155
156 const interrupting = eligible.filter((r) =>
157 toolShouldInterrupt(config.interruptMode, r.interruptMode),
158 );
159 if (interrupting.length > 0) {
160 recordInMemory(memory, info, interrupting);
161 chain = chain
162 .then(async () => {
163 let raw: unknown;
164 try {
165 raw = await $.store.get(STORE_KEY);
166 } catch {
167 return;
168 }
169 const doc = withRecorded(asStoreDoc(raw), info, interrupting);
170 try {
171 await $.store.set(STORE_KEY, doc);
172 } catch {
173 // Persistence is best-effort; memory marks still suppress repeats.
174 }
175 })
176 .catch(() => undefined);
177 try {
178 $.ui.toast(`Bouncer: ${interrupting.map((r) => r.name).join(", ")}`);
179 } catch {
180 // Headless or toastless surfaces: enforcement never depends on UI.
181 }
182 return { deny: renderCorrection(interrupting) };
183 }
184
185 const answered = await next(e);
186 if (answered !== null && typeof answered === "object" && "deny" in answered) {
187 return answered; // a lower hook already refused; nothing delivered
188 }
189 recordInMemory(memory, info, eligible);
190 chain = chain
191 .then(async () => {
192 let raw: unknown;
193 try {
194 raw = await $.store.get(STORE_KEY);
195 } catch {
196 return;
197 }
198 const doc = withRecorded(asStoreDoc(raw), info, eligible);
199 try {
200 await $.store.set(STORE_KEY, doc);
201 } catch {
202 // Best-effort, as above.
203 }
204 })
205 .catch(() => undefined);
206 if (answered !== null && typeof answered === "object" && "result" in answered) {
207 const prev = (answered as { context?: readonly string[] }).context ?? [];
208 return { ...answered, context: [...prev, renderCorrection(eligible)] };
209 }
210 return answered;
211 });
212}
213hooks/bouncer-config.ts 86 lines1// Normalized plugin configuration. Manifest userConfig avoids array
2// types (validator-safe): disabledRules is a comma-separated string.
3
4export type GlobalInterruptMode = "always" | "never";
5export type RepeatMode = "once" | "after-gap";
6
7export interface BouncerConfig {
8 enabled: boolean;
9 interruptMode: GlobalInterruptMode;
10 repeatMode: RepeatMode;
11 repeatGap: number;
12 disabledRules: string[];
13}
14
15export type PluginOptionsLike = Readonly<
16 Record<string, string | number | boolean | readonly string[]>
17>;
18
19function readString(
20 options: PluginOptionsLike,
21 key: string,
22 fallback: string,
23): string {
24 const v = options[key];
25 return typeof v === "string" && v.length > 0 ? v : fallback;
26}
27
28export function normalizeConfig(options: PluginOptionsLike): BouncerConfig {
29 const enabledRaw = options["enabled"];
30 const enabled = typeof enabledRaw === "boolean" ? enabledRaw : enabledRaw !== "false";
31 const interruptRaw = readString(options, "interruptMode", "always").toLowerCase();
32 const interruptMode: GlobalInterruptMode =
33 interruptRaw === "never" ? "never" : "always";
34 const repeatRaw = readString(options, "repeatMode", "once").toLowerCase();
35 const repeatMode: RepeatMode = repeatRaw === "after-gap" ? "after-gap" : "once";
36 const gapRaw = options["repeatGap"];
37 const repeatGap =
38 typeof gapRaw === "number" && Number.isFinite(gapRaw) && gapRaw >= 0
39 ? Math.floor(gapRaw)
40 : 10;
41 const disabledRaw = options["disabledRules"];
42 const disabledText =
43 typeof disabledRaw === "string"
44 ? disabledRaw
45 : Array.isArray(disabledRaw)
46 ? disabledRaw.join(",")
47 : "";
48 const disabledRules = disabledText
49 .split(",")
50 .map((s) => s.trim())
51 .filter((s) => s.length > 0);
52 return { enabled, interruptMode, repeatMode, repeatGap, disabledRules };
53}
54
55/**
56 * For a tool source, does this rule/global mode pair interrupt (deny)
57 * or only advise (run, then attach context)? A rule interruptMode
58 * overrides the global one. Only `always` and `never` exist;
59 * anything else falls back to global.
60 */
61export function toolShouldInterrupt(
62 global: GlobalInterruptMode,
63 rule: string | undefined,
64): boolean {
65 const mode = (rule ?? "").trim().toLowerCase();
66 if (mode === "always") return true;
67 if (mode === "never") return false;
68 return global === "always";
69}
70
71/**
72 * Per-rule repeat policy with fallback to global. Unknown rule values
73 * fall back just like interruptMode above.
74 */
75export function ruleRepeatMode(global: RepeatMode, rule: string | undefined): RepeatMode {
76 const mode = (rule ?? "").trim().toLowerCase();
77 if (mode === "once" || mode === "after-gap") return mode;
78 return global;
79}
80
81export function ruleRepeatGap(globalGap: number, rule: number | undefined): number {
82 return typeof rule === "number" && Number.isFinite(rule) && rule >= 0
83 ? Math.floor(rule)
84 : globalGap;
85}
86hooks/bouncer-rules.ts 154 lines1// Rule compilation (pure, no $ access): filename-derived names with
2// first-wins dedup across provider-ordered input. Native Claude rules
3// files are Bouncer rules only when carrying a `condition:`; their `paths:`
4// doubles as the path gate.
5
6import { compileCondition, isGlobShorthand } from "./bouncer-regex";
7import { parseRuleFile } from "./bouncer-frontmatter";
8import { parseScope } from "./bouncer-scope";
9import type { ParsedScope } from "./bouncer-scope";
10
11export interface RuleFile {
12 /** Filename-derived rule name (without .md/.mdc). */
13 name: string;
14 /** Raw markdown file text. */
15 text: string;
16 /** Human provider label for diagnostics, e.g. "project .claude/rules". */
17 label: string;
18 /** Full path, for diagnostics. */
19 file: string;
20 /**
21 * True for native Claude rules (`.claude/rules/`): the file is a Bouncer
22 * rule only when its frontmatter carries a `condition:`; otherwise it is
23 * an ordinary instruction file and is silently ignored here.
24 */
25 native: boolean;
26}
27
28export interface CompiledRule {
29 name: string;
30 description: string;
31 body: string;
32 sourceLabel: string;
33 file: string;
34 regexes: RegExp[];
35 regexSources: string[];
36 digest: string;
37 scopes: ParsedScope;
38 globs: string[];
39 interruptMode: string | undefined;
40 repeatMode: string | undefined;
41 repeatGap: number | undefined;
42}
43
44export interface DiscoveryResult {
45 rules: CompiledRule[];
46 diagnostics: string[];
47}
48
49function fnv1a(text: string): string {
50 let h = 0x811c9dc5;
51 for (let i = 0; i < text.length; i++) {
52 h ^= text.charCodeAt(i);
53 h = Math.imul(h, 0x01000193);
54 }
55 return ("0000000" + (h >>> 0).toString(16)).slice(-8);
56}
57
58export function ruleNameOf(filename: string): string | null {
59 const lower = filename.toLowerCase();
60 if (lower.endsWith(".md")) return filename.slice(0, -3);
61 if (lower.endsWith(".mdc")) return filename.slice(0, -4);
62 return null;
63}
64
65/**
66 * Compile already-read rule files. Input order is provider priority
67 * (highest first); the first file per rule name wins, later duplicates
68 * are reported as shadowed.
69 */
70export function compileRules(
71 files: readonly RuleFile[],
72 disabled: readonly string[],
73): DiscoveryResult {
74 const diagnostics: string[] = [];
75 const disabledSet: Record<string, true> = {};
76 for (const d of disabled) disabledSet[d.toLowerCase()] = true;
77 const seen: Record<string, true> = {};
78 const rules: CompiledRule[] = [];
79 for (const rf of files) {
80 const key = rf.name.toLowerCase();
81 if (seen[key]) {
82 diagnostics.push(
83 `Bouncer: "${rf.name}" shadowed — first discovery wins (${rf.label}/${rf.file}).`,
84 );
85 continue;
86 }
87 seen[key] = true;
88 if (disabledSet[key]) continue;
89 const parsed = parseRuleFile(rf.text);
90 if (!parsed) {
91 if (!rf.native) diagnostics.push(`Bouncer: "${rf.name}" has no frontmatter block; skipped.`);
92 continue;
93 }
94 const fm = parsed.frontmatter;
95 if (fm.enabled === false) continue;
96 const isBouncer = (fm.condition ?? []).length > 0;
97 if (!isBouncer) {
98 // Ordinary native rule, not a stream rule: ignore silently here.
99 // (A bouncer-rules/ file without markers gets a diagnostic below.)
100 if (!rf.native) diagnostics.push(`Bouncer: "${rf.name}" has no usable condition; skipped.`);
101 continue;
102 }
103 const body = parsed.body;
104 if (body.length === 0) {
105 diagnostics.push(`Bouncer: "${rf.name}" has an empty body; skipped.`);
106 continue;
107 }
108 // Native `paths:` doubles as the Bouncer path gate when no explicit
109 // `globs:` is given.
110 const pathGate = (fm.globs ?? []).length > 0 ? fm.globs! : (fm.paths ?? []);
111 // Legacy glob shorthand: condition looks like "*.rs".
112 let conditions = fm.condition ?? [];
113 const scopes = parseScope(fm.scope);
114 if (conditions.length === 1 && isGlobShorthand(conditions[0]!)) {
115 const glob = conditions[0]!.trim();
116 conditions = [".*"];
117 scopes.entries.push({ kind: "tool", name: "edit", glob });
118 scopes.entries.push({ kind: "tool", name: "write", glob });
119 scopes.explicit = true;
120 }
121 const regexes: RegExp[] = [];
122 const regexSources: string[] = [];
123 for (const cond of conditions) {
124 const re = compileCondition(cond);
125 if (re === null) {
126 diagnostics.push(`Bouncer: "${rf.name}" drops invalid regex ${JSON.stringify(cond)}.`);
127 continue;
128 }
129 regexes.push(re);
130 regexSources.push(cond);
131 }
132 if (regexes.length === 0) {
133 diagnostics.push(`Bouncer: "${rf.name}" has no usable condition; skipped.`);
134 continue;
135 }
136 rules.push({
137 name: rf.name,
138 description: typeof fm.description === "string" ? fm.description : "",
139 body,
140 sourceLabel: rf.label,
141 file: rf.file,
142 regexes,
143 regexSources,
144 digest: fnv1a(rf.name + "\n" + regexSources.join("\n") + "\n" + body),
145 scopes,
146 globs: pathGate,
147 interruptMode: fm.interruptMode,
148 repeatMode: fm.repeatMode,
149 repeatGap: fm.repeatGap,
150 });
151 }
152 return { rules, diagnostics };
153}
154hooks/bouncer-match.ts 111 lines1// Matching rule conditions against tool calls.
2// Tool matching denies/annotates before execution.
3
4import { anyGlobMatches } from "./bouncer-glob";
5import type { CompiledRule } from "./bouncer-rules";
6export interface ToolCandidate {
7 /** File path when the tool names one, else undefined. */
8 path?: string;
9 /** Introduced content (Edit new_string, Write content, Bash command…). */
10 text: string;
11}
12
13function stringField(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
14 for (const k of keys) {
15 const v = args[k];
16 if (typeof v === "string" && v.length > 0) return v;
17 }
18 return undefined;
19}
20
21/** Extract matchable (path, text) pairs from a tool.call event. */
22export function toolCandidates(tool: string, args: Record<string, unknown>): ToolCandidate[] {
23 const lower = tool.toLowerCase();
24 if (lower === "edit") {
25 const path = stringField(args, ["file_path", "filePath", "path"]);
26 const introduced = stringField(args, ["new_string", "newString", "content"]);
27 if (introduced === undefined) return [];
28 return [{ path, text: introduced }];
29 }
30 if (lower === "write") {
31 const path = stringField(args, ["file_path", "filePath", "path"]);
32 const content = stringField(args, ["content", "new_string", "newString", "text"]);
33 if (content === undefined) return [];
34 return [{ path, text: content }];
35 }
36 if (lower === "notebookedit" || lower === "notebook_edit") {
37 const path = stringField(args, ["notebook_path", "notebookPath", "file_path"]);
38 const src = stringField(args, ["new_source", "newSource", "content"]);
39 if (src === undefined) return [];
40 return [{ path, text: src }];
41 }
42 if (lower === "bash" || lower === "powershell") {
43 const cmd = stringField(args, ["command", "cmd", "script"]);
44 return cmd === undefined ? [] : [{ text: cmd }];
45 }
46 const path = stringField(args, ["file_path", "filePath", "path", "filename", "file"]);
47 const parts: string[] = [];
48 for (const v of Object.values(args)) {
49 if (typeof v === "string" && v.length > 0 && v.length <= 200000) parts.push(v);
50 }
51 if (parts.length === 0) return [];
52 return [{ path, text: parts.join("\n") }];
53}
54
55/**
56 * Match fully-typed tool input against every rule. Returns distinct
57 * matching rules in discovery order. ast-only rules never match.
58 */
59export function matchToolRules(
60 rules: readonly CompiledRule[],
61 tool: string,
62 candidates: readonly ToolCandidate[],
63): CompiledRule[] {
64 const toolLower = tool.toLowerCase();
65 const matched: CompiledRule[] = [];
66 for (const rule of rules) {
67 if (rule.regexes.length === 0) continue;
68 let hit = false;
69 for (const candidate of candidates) {
70 if (hit) break;
71 for (const entry of rule.scopes.entries) {
72 if (hit) break;
73 if (entry.name !== "*" && entry.name !== toolLower) continue;
74 if (entry.glob) {
75 if (!candidate.path || !anyGlobMatches([entry.glob], candidate.path)) continue;
76 } else if (rule.globs.length > 0) {
77 if (!candidate.path || !anyGlobMatches(rule.globs, candidate.path)) continue;
78 }
79 for (const re of rule.regexes) {
80 let ok = false;
81 try {
82 ok = re.test(candidate.text);
83 } catch {
84 ok = false;
85 }
86 if (ok) {
87 hit = true;
88 break;
89 }
90 }
91 }
92 }
93 if (hit) matched.push(rule);
94 }
95 return matched;
96}
97
98/** Render the denial/correction text the model reads as an error result. */
99export function renderCorrection(rules: readonly CompiledRule[]): string {
100 const blocks = rules.map((r) => {
101 const head = r.description ? `${r.name}: ${r.description}` : r.name;
102 return `[Bouncer ${head}]\n${r.body}`;
103 });
104 return (
105 "Bouncer rule matched; the tool call was not executed. Read the rule instruction(s) below, then retry the corrected call immediately in this turn using your best judgment. " +
106 "Do not ask the user to choose between alternatives and do not wait for input — proceed with the compliant option that fits, and mention the substitution briefly as you do it.\n\n" +
107 blocks.join("\n\n---\n\n")
108 );
109}
110
111hooks/bouncer-state.ts 110 lines1// Repeat suppression: `once` per session+loop, `after-gap` by completed
2// user turns. All persistence primitives here are pure functions over
3// plain data; register.ts owns the memory map and makes every $.store
4// call directly (hook modules must spell $.noun.event(...) at the call
5// site, never pass $ into helpers). A rule whose digest changed since
6// its mark is treated as edited and re-arms.
7
8import type { BouncerConfig } from "./bouncer-config";
9import { ruleRepeatGap, ruleRepeatMode } from "./bouncer-config";
10import type { CompiledRule } from "./bouncer-rules";
11
12export interface TurnInfo {
13 sessionId: string;
14 loop: string; // agentId or "main"
15 turns: number; // completed user turns via $.session.turns()
16}
17
18export interface Mark {
19 turn: number;
20 digest: string;
21}
22
23/** loop -> lowercase rule name -> mark */
24export type LoopMarks = Record<string, Record<string, Mark>>;
25
26export const STORE_KEY = "bouncer.v1.marks";
27const MAX_SESSIONS = 20;
28
29function isMark(v: unknown): v is Mark {
30 if (typeof v !== "object" || v === null) return false;
31 const m = v as Record<string, unknown>;
32 return typeof m["turn"] === "number" && typeof m["digest"] === "string";
33}
34
35/** Normalize unknown store content to sessionId -> LoopMarks. */
36export function asStoreDoc(v: unknown): Record<string, LoopMarks> {
37 if (typeof v !== "object" || v === null) return {};
38 const doc: Record<string, LoopMarks> = {};
39 for (const [sid, loops] of Object.entries(v as Record<string, unknown>)) {
40 if (typeof loops !== "object" || loops === null) continue;
41 const clean: LoopMarks = {};
42 for (const [loop, rules] of Object.entries(loops as Record<string, unknown>)) {
43 if (typeof rules !== "object" || rules === null) continue;
44 const kept: Record<string, Mark> = {};
45 for (const [rule, mark] of Object.entries(rules as Record<string, unknown>)) {
46 if (isMark(mark)) kept[rule] = mark;
47 }
48 clean[loop] = kept;
49 }
50 doc[sid] = clean;
51 }
52 return doc;
53}
54
55/** Marks for one session (empty when never persisted). */
56export function marksForSession(doc: Record<string, LoopMarks>, sessionId: string): LoopMarks {
57 const loops = doc[sessionId];
58 if (!loops) return {};
59 const copy: LoopMarks = {};
60 for (const [loop, rules] of Object.entries(loops)) copy[loop] = { ...rules };
61 return copy;
62}
63
64/** Keep only rules still eligible under the repeat policy. */
65export function selectEligible(
66 rules: readonly CompiledRule[],
67 marks: LoopMarks,
68 info: TurnInfo,
69 config: BouncerConfig,
70): CompiledRule[] {
71 const perLoop = marks[info.loop] ?? {};
72 return rules.filter((r) => {
73 const mark = perLoop[r.name.toLowerCase()];
74 if (!mark) return true;
75 if (mark.digest !== r.digest) return true; // rule edited: re-arm
76 if (ruleRepeatMode(config.repeatMode, r.repeatMode) === "once") return false;
77 return info.turns - mark.turn >= ruleRepeatGap(config.repeatGap, r.repeatGap);
78 });
79}
80
81/** Record delivery in a memory map (mutates and returns it). */
82export function recordInMemory(
83 memory: Record<string, LoopMarks>,
84 info: TurnInfo,
85 rules: readonly CompiledRule[],
86): Record<string, LoopMarks> {
87 memory[info.sessionId] ??= {};
88 memory[info.sessionId]![info.loop] ??= {};
89 const perLoop = memory[info.sessionId]![info.loop]!;
90 for (const r of rules) perLoop[r.name.toLowerCase()] = { turn: info.turns, digest: r.digest };
91 return memory;
92}
93
94/** Fold delivery marks into a store doc, pruning oldest sessions. */
95export function withRecorded(
96 doc: Record<string, LoopMarks>,
97 info: TurnInfo,
98 rules: readonly CompiledRule[],
99): Record<string, LoopMarks> {
100 const out: Record<string, LoopMarks> = { ...doc };
101 const loops: LoopMarks = { ...(out[info.sessionId] ?? {}) };
102 const perLoop: Record<string, Mark> = { ...(loops[info.loop] ?? {}) };
103 for (const r of rules) perLoop[r.name.toLowerCase()] = { turn: info.turns, digest: r.digest };
104 loops[info.loop] = perLoop;
105 out[info.sessionId] = loops;
106 const ids = Object.keys(out);
107 for (const stale of ids.slice(0, Math.max(0, ids.length - MAX_SESSIONS))) delete out[stale];
108 return out;
109}
110hooks/bouncer-regex.ts 59 lines1// Bouncer regex conditions: leading (?i)(?m)(?s) translated to JS flags.
2// Anything else is left to the JS RegExp compiler; invalid patterns
3// compile to null and the rule is skipped with a diagnostic.
4
5export interface SplitFlags {
6 flags: string;
7 source: string;
8}
9
10/** Strip leading inline flag groups like (?i), (?im), (?i)(?s). */
11export function splitLeadingFlags(pattern: string): SplitFlags {
12 let rest = pattern;
13 let i = false;
14 let m = false;
15 let s = false;
16 for (;;) {
17 const m0 = /^\(\?([ims]+)\)/.exec(rest);
18 if (!m0) break;
19 for (const ch of m0[1]) {
20 if (ch === "i") i = true;
21 else if (ch === "m") m = true;
22 else if (ch === "s") s = true;
23 }
24 rest = rest.slice(m0[0].length);
25 }
26 let flags = "";
27 if (i) flags += "i";
28 if (m) flags += "m";
29 if (s) flags += "s";
30 return { flags, source: rest };
31}
32
33/** Compile one condition. Returns null when unusable (warn + skip). */
34export function compileCondition(pattern: string): RegExp | null {
35 if (typeof pattern !== "string" || pattern.length === 0) return null;
36 try {
37 const { flags, source } = splitLeadingFlags(pattern);
38 if (source.length === 0) return null;
39 return new RegExp(source, flags);
40 } catch {
41 return null;
42 }
43}
44
45/**
46 * Conservative legacy-glob heuristic: a condition that looks like a file
47 * glob (e.g. "*.rs") is shorthand for edit/write on that glob with `.*`.
48 * Only fires on strings with glob characters and none of the common
49 * regex-only metacharacters.
50 */
51export function isGlobShorthand(value: string): boolean {
52 if (typeof value !== "string") return false;
53 const s = value.trim();
54 if (s.length === 0) return false;
55 if (!s.includes("*") && !s.includes("?")) return false;
56 if (/[()\[\]\\|^$+{}]/.test(s)) return false;
57 return true;
58}
59hooks/bouncer-frontmatter.ts 164 lines1// Line-oriented frontmatter parser for Bouncer rule files. No YAML
2// dependency: supports the subset OMP rules use in practice — scalar
3// strings (plain/single/double quoted), booleans, numbers, inline
4// `[a, b]` lists and block `- item` lists. Unknown shapes survive as
5// raw strings so consumers can ignore them; malformed typed fields
6// never abort discovery.
7
8export interface RuleFrontmatter {
9 enabled?: boolean;
10 description?: string;
11 interruptMode?: string;
12 repeatMode?: string;
13 repeatGap?: number;
14 condition?: string[];
15 scope?: string[];
16 globs?: string[];
17 /** Native Claude rules path gate (`paths:`); used as Bouncer path gate when set. */
18 paths?: string[];
19 raw: Record<string, unknown>;
20}
21
22function unquote(s: string): string {
23 const t = s.trim();
24 if (t.length >= 2) {
25 const first = t[0];
26 const last = t[t.length - 1];
27 if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
28 const inner = t.slice(1, -1);
29 return first === '"' ? inner.replace(/\\(.)/g, "$1") : inner.replace(/''/g, "'");
30 }
31 }
32 return t;
33}
34
35function splitCsv(s: string): string[] {
36 const parts: string[] = [];
37 let cur = "";
38 let quote: string | null = null;
39 for (let i = 0; i < s.length; i++) {
40 const c = s[i]!;
41 if (quote !== null) {
42 cur += c;
43 if (c === quote) quote = null;
44 } else if (c === '"' || c === "'") {
45 quote = c;
46 cur += c;
47 } else if (c === ",") {
48 parts.push(cur);
49 cur = "";
50 } else {
51 cur += c;
52 }
53 }
54 parts.push(cur);
55 return parts.map((p) => unquote(p)).filter((p) => p.length > 0);
56}
57
58function parseInlineList(s: string): string[] | null {
59 const t = s.trim();
60 if (!t.startsWith("[") || !t.endsWith("]")) return null;
61 const inner = t.slice(1, -1).trim();
62 if (inner.length === 0) return [];
63 return splitCsv(inner);
64}
65
66function toStringList(value: unknown): string[] | null {
67 if (typeof value === "string") {
68 const inline = parseInlineList(value);
69 if (inline !== null) return inline;
70 // A bare comma string counts as a list for scope/condition fields.
71 if (value.includes(",")) return splitCsv(value);
72 const single = unquote(value).trim();
73 return single.length > 0 ? [single] : [];
74 }
75 if (Array.isArray(value)) {
76 const out: string[] = [];
77 for (const item of value) {
78 if (typeof item === "string") {
79 const one = unquote(item).trim();
80 if (one.length > 0) out.push(one);
81 } else if (typeof item === "number" || typeof item === "boolean") {
82 out.push(String(item));
83 }
84 }
85 return out;
86 }
87 return null;
88}
89
90export interface ParsedRuleFile {
91 frontmatter: RuleFrontmatter;
92 body: string;
93}
94
95/** Split `---` frontmatter from body. Returns null when no frontmatter. */
96export function parseRuleFile(text: string): ParsedRuleFile | null {
97 const lines = text.split("\n");
98 if (lines.length === 0 || lines[0]!.trim() !== "---") return null;
99 let close = -1;
100 for (let i = 1; i < lines.length; i++) {
101 const t = lines[i]!.trim();
102 if (t === "---" || t === "...") {
103 close = i;
104 break;
105 }
106 }
107 if (close < 0) return null;
108 const raw: Record<string, unknown> = {};
109 let i = 1;
110 while (i < close) {
111 const line = lines[i]!;
112 i++;
113 if (line.trim().length === 0 || line.trim().startsWith("#")) continue;
114 const colon = line.indexOf(":");
115 if (colon < 0) continue;
116 const key = line.slice(0, colon).trim();
117 if (key.length === 0) continue;
118 let rest = line.slice(colon + 1);
119 if (rest.trim().length === 0) {
120 // Possible block list: consume following `- item` lines.
121 const items: string[] = [];
122 while (i < close && /^\s*-\s+/.test(lines[i]!)) {
123 items.push(unquote(lines[i]!.replace(/^\s*-\s+/, "")).trim());
124 i++;
125 }
126 raw[key] = items;
127 } else {
128 raw[key] = unquote(rest);
129 }
130 }
131 const body = lines.slice(close + 1).join("\n").trim();
132 const conditionRaw = raw["condition"];
133 const frontmatter: RuleFrontmatter = { raw };
134 if (typeof raw["enabled"] === "string") {
135 const v = raw["enabled"].toLowerCase();
136 if (v === "true") frontmatter.enabled = true;
137 else if (v === "false") frontmatter.enabled = false;
138 } else if (typeof raw["enabled"] === "boolean") {
139 frontmatter.enabled = raw["enabled"];
140 }
141 if (typeof raw["description"] === "string") {
142 frontmatter.description = raw["description"];
143 }
144 if (typeof raw["interruptMode"] === "string") {
145 frontmatter.interruptMode = raw["interruptMode"].trim();
146 }
147 if (typeof raw["repeatMode"] === "string") {
148 frontmatter.repeatMode = raw["repeatMode"].trim();
149 }
150 const gapRaw = raw["repeatGap"];
151 const gapNum =
152 typeof gapRaw === "number" ? gapRaw : typeof gapRaw === "string" ? Number(gapRaw.trim()) : NaN;
153 if (Number.isFinite(gapNum) && gapNum >= 0) frontmatter.repeatGap = Math.floor(gapNum);
154 const globs = toStringList(raw["globs"]);
155 if (globs !== null) frontmatter.globs = globs;
156 const paths = toStringList(raw["paths"]);
157 if (paths !== null) frontmatter.paths = paths;
158 const scope = toStringList(raw["scope"]);
159 if (scope !== null) frontmatter.scope = scope;
160 const condition = toStringList(conditionRaw);
161 if (condition !== null) frontmatter.condition = condition;
162 return { frontmatter, body };
163}
164hooks/bouncer-scope.ts 103 lines1// Scope parsing for Bouncer rules. Tokens (comma-aware, parens/quotes
2// respected): `tool`/`toolcall`, `tool:<name>`,
3// `tool:<name>(<glob>)`, or a bare tool name such as `bash`.
4// No explicit scope means every tool. Legacy `text`/`thinking`/`prose`
5// tokens are ignored and never match.
6
7export interface ToolScope {
8 kind: "tool";
9 /** Lower-cased tool name, or "*" for every tool. */
10 name: string;
11 glob?: string;
12}
13
14export type ScopeEntry = ToolScope;
15
16export interface ParsedScope {
17 explicit: boolean;
18 entries: ScopeEntry[];
19}
20
21/** Split on commas ignoring commas inside parens and quotes. */
22export function splitScopeList(s: string): string[] {
23 const parts: string[] = [];
24 let cur = "";
25 let depth = 0;
26 let quote: string | null = null;
27 for (let i = 0; i < s.length; i++) {
28 const c = s[i]!;
29 if (quote !== null) {
30 cur += c;
31 if (c === quote) quote = null;
32 } else if (c === '"' || c === "'") {
33 quote = c;
34 cur += c;
35 } else if (c === "(") {
36 depth++;
37 cur += c;
38 } else if (c === ")") {
39 if (depth > 0) depth--;
40 cur += c;
41 } else if (c === "," && depth === 0) {
42 parts.push(cur);
43 cur = "";
44 } else {
45 cur += c;
46 }
47 }
48 parts.push(cur);
49 return parts.map((p) => p.trim()).filter((p) => p.length > 0);
50}
51
52function parseToken(token: string): ScopeEntry | null {
53 const t = token.trim().replace(/^['"]|['"]$/g, "");
54 const lower = t.toLowerCase();
55 // Legacy prose/thinking scopes are unsupported: ignore them so a
56 // prose-only rule matches nothing instead of every tool.
57 if (lower === "text" || lower === "prose" || lower === "thinking") return null;
58 if (lower === "tool" || lower === "toolcall" || lower === "tools") {
59 return { kind: "tool", name: "*" };
60 }
61 const toolPrefix = /^tool\s*:\s*(.+)$/i.exec(t);
62 if (toolPrefix) {
63 const rest = toolPrefix[1]!.trim();
64 const paren = /^([^()]+)\((.+)\)$/.exec(rest);
65 if (paren) {
66 const name = paren[1]!.trim().toLowerCase();
67 const glob = paren[2]!.trim().replace(/^['"]|['"]$/g, "");
68 if (name.length === 0 || glob.length === 0) return null;
69 return { kind: "tool", name, glob };
70 }
71 const name = rest.replace(/^['"]|['"]$/g, "").trim().toLowerCase();
72 if (name.length === 0) return null;
73 return { kind: "tool", name };
74 }
75 // Bare tool name such as `bash` (OMP accepts it).
76 if (/^[a-z0-9_][a-z0-9_\-]*$/i.test(t) && !t.includes(" ")) {
77 return { kind: "tool", name: lower };
78 }
79 return null;
80}
81
82export function parseScope(raw: readonly string[] | undefined): ParsedScope {
83 if (!raw || raw.length === 0) {
84 return {
85 explicit: false,
86 entries: [{ kind: "tool", name: "*" }],
87 };
88 }
89 const joined = raw.length === 1 && raw[0]!.includes(",") ? splitScopeList(raw[0]!) : raw.flatMap((s) => splitScopeList(s));
90 const entries: ScopeEntry[] = [];
91 for (const token of joined) {
92 const entry = parseToken(token);
93 if (entry !== null) entries.push(entry);
94 }
95 if (entries.length === 0) {
96 return {
97 explicit: true,
98 entries: [],
99 };
100 }
101 return { explicit: true, entries };
102}
103hooks/bouncer-glob.ts 97 lines1// Minimal glob matcher for Bouncer path gates. Supports `*`, `**`, `?`,
2// `{a,b}` alternation. Matches normalized full paths and basenames.
3// Case-sensitive (paths); tool names are matched case-insensitively
4// elsewhere, not here.
5
6/** Convert one glob to a RegExp source anchored full-string. */
7export function globToSource(glob: string): string {
8 const g = glob.trim().replace(/\\/g, "/");
9 let out = "";
10 let i = 0;
11 const n = g.length;
12 while (i < n) {
13 const c = g[i]!;
14 if (c === "*") {
15 if (g[i + 1] === "*") {
16 if (g[i + 2] === "/") {
17 out += "(?:.*/)?"; // `**/` matches zero or more dirs
18 i += 3;
19 } else {
20 out += ".*";
21 i += 2;
22 }
23 } else {
24 out += "[^/]*";
25 i += 1;
26 }
27 } else if (c === "?") {
28 out += "[^/]";
29 i += 1;
30 } else if (c === "{") {
31 const close = g.indexOf("}", i);
32 if (close < 0) {
33 out += "\\{";
34 i += 1;
35 } else {
36 const parts = g.slice(i + 1, close).split(",");
37 out += "(?:" + parts.map((p) => globToSource(p)).join("|") + ")";
38 i = close + 1;
39 }
40 } else if (c === ".") {
41 out += "\\.";
42 i += 1;
43 } else if (c === "+") {
44 out += "\\+";
45 i += 1;
46 } else if (c === "^") {
47 out += "\\^";
48 i += 1;
49 } else if (c === "$") {
50 out += "\\$";
51 i += 1;
52 } else if (c === "(") {
53 out += "\\(";
54 i += 1;
55 } else if (c === ")") {
56 out += "\\)";
57 i += 1;
58 } else if (c === "|") {
59 out += "\\|";
60 i += 1;
61 } else if (c === "[") {
62 out += "\\[";
63 i += 1;
64 } else if (c === "]") {
65 out += "\\]";
66 i += 1;
67 } else if (c === "\\") {
68 out += "\\\\";
69 i += 1;
70 } else {
71 out += c;
72 i += 1;
73 }
74 }
75 return out;
76}
77
78export function globMatches(glob: string, path: string): boolean {
79 try {
80 const re = new RegExp("^(?:" + globToSource(glob) + ")$");
81 const p = path.replace(/\\/g, "/");
82 const slash = p.lastIndexOf("/");
83 const base = slash < 0 ? p : p.slice(slash + 1);
84 return re.test(p) || re.test(base);
85 } catch {
86 return false;
87 }
88}
89
90/** True when any glob matches the path (full or basename). */
91export function anyGlobMatches(globs: readonly string[], path: string): boolean {
92 for (const g of globs) {
93 if (globMatches(g, path)) return true;
94 }
95 return false;
96}
97