SLOPSHOPPER

bouncer

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

newguardtoast
★ 1v0.1.0MITupdated 2026-10-05ffalor/ffalor-plugins/plugins/bouncer
A shopper browsing a rack in a slop shop
README

Bouncer for Claude Code

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).

Requirements

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.

Install

claude plugin marketplace add ffalor/ffalor-plugins
claude plugin install bouncer@ffalor-plugins

Or session-only: claude --plugin-dir <checkout>/ffalor-plugins/plugins/bouncer.

Forging rules with /omfg

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.

Rule locations

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.

  1. <project>/.claude/rules/*.md (also loaded as native context)
  2. <project>/.claude/bouncer-rules/*.md (Bouncer-only; never loaded as context)
  3. ~/.claude/rules/*.md
  4. ~/.claude/bouncer-rules/*.md

When 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).

Configuration

Per-rule fields

  • 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.

Plugin options

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.

Example

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.

Verify

claude plugin validate ./plugins/bouncer --strict --json
Source 9 files
hooks/register.ts 213 lines
1// 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}
213
hooks/bouncer-config.ts 86 lines
1// 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}
86
hooks/bouncer-rules.ts 154 lines
1// 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}
154
hooks/bouncer-match.ts 111 lines
1// 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
111
hooks/bouncer-state.ts 110 lines
1// 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}
110
hooks/bouncer-regex.ts 59 lines
1// 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}
59
hooks/bouncer-frontmatter.ts 164 lines
1// 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}
164
hooks/bouncer-scope.ts 103 lines
1// 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}
103
hooks/bouncer-glob.ts 97 lines
1// 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