/**
* Grounding the stage-2 verdict against CC's rule names (pure).
*
* Claude Code's stage-2 classifier emits `Exact BLOCK Rule Name`
* when an action matches a BLOCK rule. CC itself discards that and shows a fixed
* "Blocked by classifier"; we instead validate the category against an index of
* the rule names actually present in the embedded ruleset, and derive the tier
* (hard vs soft) from which section the name was found in — never from a field
* the model chose freely.
*
* A block citing a category that does not exist is still a block, but tagged
* `unmatched`, so a hallucinated name cannot borrow a real HARD rule's authority.
* The rule's own name is what the user sees; the model's wording is kept as
* commentary that is never mistaken for fact. This is decision 1 in
* docs/decisions/auto-mode.md, re-expressed against CC's category scheme in
* place of our old H1/S4/A2 ids.
*/
export type BlockTier = "hard_deny" | "soft_deny";
export interface IndexedRule {
/** The rule's exact name as it appears in the ruleset (e.g. "Git Destructive"). */
name: string;
tier: BlockTier;
}
export interface RuleIndex {
rules: IndexedRule[];
/** Keyed by normalized name, for case/whitespace-insensitive lookup. */
byName: Map;
}
/** Section headings in CC's ruleset that bound the block-rule lists. */
const HARD_HEADING = "## HARD BLOCK";
const SOFT_HEADING = "## SOFT BLOCK";
const ALLOW_HEADING = "## ALLOW (exceptions)";
/** Compare names ignoring case, surrounding punctuation, and whitespace runs. */
export function normalizeName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/[.,:;]+$/, "");
}
/**
* The rule name at the head of a top-level bullet line, or undefined if the line
* is not a rule entry. A rule line looks like `- Name [named+specifics …]: text`
* or `- Name: text`; the name is what precedes the first ` [` qualifier or `:`.
* Indented lines (sub-bullets) and non-bullet prose are ignored.
*/
function ruleNameFromLine(line: string): string | undefined {
if (!line.startsWith("- ")) return undefined; // top-level bullets only
const body = line.slice(2);
// Cut at the first " [" (the [named+specifics …] qualifier) or the first ":".
const bracket = body.indexOf(" [");
const colon = body.indexOf(":");
let end = body.length;
if (bracket >= 0) end = Math.min(end, bracket);
if (colon >= 0) end = Math.min(end, colon);
const name = body.slice(0, end).replace(/\*/g, "").trim();
return name.length > 0 ? name : undefined;
}
/** The text between two markers (exclusive), or "" if the bounds are missing. */
function sliceSection(text: string, from: string, to: string): string {
const start = text.indexOf(from);
if (start < 0) return "";
const end = text.indexOf(to, start + from.length);
return text.slice(start + from.length, end < 0 ? text.length : end);
}
function collectRules(section: string, tier: BlockTier, out: IndexedRule[], byName: Map): void {
for (const line of section.split("\n")) {
const name = ruleNameFromLine(line);
if (!name) continue;
const nameKey = normalizeName(name);
if (byName.has(nameKey)) continue; // a name appearing in two sections keeps its first (HARD) tier
const rule: IndexedRule = { name, tier };
out.push(rule);
byName.set(nameKey, rule);
// Also key the FULL line text (when it differs), so a verdict quoting the
// whole rule line still grounds. Built-in and user-appended lines get this
// uniformly — user extras especially need it, since they have no short
// `Name:` head and may contain `*`s that the derived name strips.
const fullKey = normalizeName(line.slice(2));
if (fullKey && fullKey !== nameKey && !byName.has(fullKey)) byName.set(fullKey, rule);
}
}
/**
* Parse the block-rule names out of the embedded ruleset (user rule extras
* included — buildRuleset has already appended them into these sections, so
* they are indexed by the same scan as the built-ins). HARD BLOCK names win a
* tie over SOFT (a HARD rule must never be downgraded by a same-named soft
* one). ALLOW names are not block categories; they are intentionally not
* indexed, so a block citing an allow name lands as `unmatched` rather than a
* real block.
*/
export function buildCategoryIndex(ruleset: string): RuleIndex {
const rules: IndexedRule[] = [];
const byName = new Map();
collectRules(sliceSection(ruleset, HARD_HEADING, SOFT_HEADING), "hard_deny", rules, byName);
collectRules(sliceSection(ruleset, SOFT_HEADING, ALLOW_HEADING), "soft_deny", rules, byName);
return { rules, byName };
}
/** Look up a category the classifier emitted. undefined ⇒ not a known BLOCK rule. */
export function groundCategory(index: RuleIndex, category: string | undefined): IndexedRule | undefined {
if (!category) return undefined;
return index.byName.get(normalizeName(category));
}