A council of specialist reviewers that gates risky tool calls before they run. Rules decide the tier; model members give a second opinion; anything uncertain…

A Claude Code mod that gates risky tool calls before they run. Rules sort every call into a tier. Calls that need a second opinion go to a model reviewer, and anything uncertain comes to you.
The rules tier is the safety boundary. The model reviewers are a second opinion: they can catch what a pattern misses, but they never widen what the rules allow. This is not a sandbox or a security product (see What it does not protect against).
Status: all six stages done, with the final deliverables (SPEC §21–22), after the 0.5.0 hardening. Available now: the rules; three model reviewers (destructive operations, diffs, and git and databases) with routing between them; the full council with your project's own checks for big operations; the secrets scan, read-only previews, review rounds and lockout, the approve cache, escalation to you, allow rules offered after "allow once", /council and /council report, shadow mode and bypass, fail-closed handling and the audit log, the theme (with a plain mode), and the debate pane, the council check band, the wipe counter, the threat meter and the epic drop. The 0.5.0 release closed the rules-tier and pipeline findings of the Stage 5 review (docs/REVIEW-2026-10.md). Headless live checks ran on Claude Code 2.1.294; the interactive screens (the dialogs, the pane, the band, the mode label) still await a check in a terminal. See ROADMAP.md for what's left, SPEC.md for the original spec and DESIGN.md for how it works.
Turn on shadow mode while you tune the rules: /council shadow on for one session, or "Shadow mode" in /config. Reviewers still run and log their verdicts to the audit log, but they never refuse. The block tier, the ask tier, protected paths and the secrets scan still enforce. The label council: shadow shows by the prompt while it is on. Until the audit log has a line, a one-time toast at session start suggests it.
Requires Claude Code 2.1.287 or later (mods are on by default).
claude --plugin-dir ./mods/council-of-elrondclaude plugin marketplace add Deluha/council-of-elrond, then claude plugin install council-of-elrond@council-of-elrond. Other ways, updating and removing: docs/INSTALL.md.CLAUDE_CODE_PLUGIN_DIRS to the folder's absolute path in the environment or in ~/.claude/settings.json under env.Check it loaded: /plugin shows the mods line, and claude plugin validate ./mods/council-of-elrond lists its hooks.
| Tier | What happens | | :- | :- | | allow | Passes untouched, with no model call. Anything no rule matches is allowed. | | review | A model reviewer gives a verdict: approve runs it, revise or block refuses it. | | ask | Comes straight to you. | | block | Refused, with the rule's reason. No model call, no question. |
Compound commands (&&, ;, |, $(...), backticks, subshells, sh -c '...', eval, and heredocs fed to a shell) are split, and the strictest part decides.
Shipped defaults:
/, your home directory, the project root, or anything outside the project;sudo, doas, su;.env*, CI config, migrations folders, .git/, .claude/council-of-elrond/, .claude/settings*.json.Approve never pre-approves. An approved call still goes through Claude Code's normal permission check and prompt. The mod never takes part in the permission decision.
Subagents are gated too. A subagent's tool calls go through the same pipeline as the main conversation's, and their audit lines carry the subagent's agentId (checked live on Claude Code 2.1.294, with the subagent in the background and in the foreground).
A review-tier call goes to exactly one model reviewer, the one its rule names (member and profile in a rule), unless it is a big operation (see The full council). Each reviewer has a short checklist of its own and sees only what that checklist needs.
| Reviewer | Takes | Sees | Checks | | :- | :- | :- | :- | | Destructive operations (Gandalf, gandalf) | deletes, moves, overwrites, scripts, infrastructure, anything no rule names | the call, the preview, scripts Claude wrote this session | reversibility, width, reach, backups, intent | | Diffs (Legolas, legolas) | Write, Edit, NotebookEdit | a diff built from the call against the file as it stands (cut to the diff line limit), the file's git status | matches the request, scope creep, weakened tests, disabled checks, dependency/CI/config changes, obvious breakage. No style review. | | Git (Aragorn, aragorn, profile git) | push, merge, rebase, reset, tag, amend and other history changes | the command, the branch, remote and commits it would send or bring in, recent history with remote branches marked, your protected branches | protected target, force, rewriting pushed history, unrelated commits | | Database (Aragorn, aragorn, profile database) | SQL clients, migrations, .sql files | the command, SQL from -c/-e/heredocs, .sql files it names inside the project, or a SQL file's diff; which production patterns match | rollback path, destructive DDL, UPDATE/DELETE without WHERE, production-looking target, transactions, long locks |
Gandalf is the fallback. A call goes to Gandalf when its rule names nobody, when the named reviewer is switched off, when a compound command's parts name different reviewers (one specialist would judge only its own part), or when a rule names the diff reviewer for something with no file. If Gandalf is off too, the call comes to you. /council test "<command>" shows who would review and why.
Big operations go to every enabled reviewer at once instead of one. By default they are a git push, a merge into a protected branch (git merge while a protected branch is checked out, or gh pr merge) and a database migration.
git push && psql … gets both.git diff. When the remote does not have the branch yet, the diff is against the remote's default branch (<remote>/HEAD), the changes since the branch point.opus by default), not its own./config). By default they run in parallel. "Full council one at a time" asks them in turn instead, each getting what is left of the deadline, and stops at the first block.Project checks (Gimli, gimli) run alongside: your tests, lint and typecheck, from gimli.commands in rules.json:
{
"schemaVersion": 1,
"gimli": {
"commands": [
{ "name": "tests", "argv": ["npm", "test"], "timeoutMs": 300000 },
{ "name": "lint", "argv": ["npx", "eslint", "."] }
]
},
"bigOperations": ["publish", "/^kubectl\\s+apply(\\s|$)/"]
}
rules.json is a protected path, so Claude can't add a command without asking you.timeoutMs says otherwise, at most 600 s. It runs outside the reviewers' deadline, so a council can take as long as the longer of the two.["env", "CI=1", "npm", "test"].Changing what counts as big: bigOperations in rules.json adds rule ids, /regex/flags matched against each shell part, or merge-to-protected; it only adds to the shipped list. Only calls the rules already send to review can be big operations: a blocked call stays refused, and an ask-tier call still comes to you. To send big operations to one reviewer like any other call, turn off "Full council for big operations" in /config. /council test "<command>" shows when a call would go to the full council, who would sit and which checks would run.
Put project overrides in .claude/council-of-elrond/rules.json:
{
"schemaVersion": 1,
"rules": [
{
"id": "allow-clean-build",
"tier": "allow",
"tools": ["Bash"],
"command": "^rm -rf (build|dist)$",
"reason": "Build output is disposable here."
},
{
"id": "no-terraform-destroy",
"tier": "block",
"tools": ["Bash"],
"command": "^terraform destroy",
"reason": "Infrastructure is torn down by CI only."
},
{
"id": "review-mcp-db",
"tier": "review",
"tools": ["mcp__db__*"],
"reason": "Database MCP calls."
}
],
"disableRules": ["shell-move"],
"protectedPaths": ["secrets/**"],
"protectedBranches": ["staging"],
"productionPatterns": ["\\bprd-"],
"models": { "gandalf": "opus" }
}
Rule fields:
tools: exact names, globs (mcp__*) or /regex/flags.command: a regex matched against each shell part, with quotes removed and wrappers such as sudo, env and nice stripped.path: a regex matched against the file path relative to the project root.input: a regex matched against the call's JSON.member and profile: who reviews (gandalf, legolas, aragorn with git or database).How overrides combine with the shipped rules:
A broken file is ignored whole. The shipped rules stay in force, and the errors are listed by field in the transcript.
The overrides file is itself a protected path, so Claude can't edit it without asking you.
| Reviewer | Built-in default | | :- | :- | | Destructive operations (Gandalf) | sonnet | | Diffs (Legolas) | sonnet | | Git and database (Aragorn, both profiles) | opus | | Full council (every member, for big operations) | opus |
Aliases resolve to the newest model of that family your Claude Code build knows. A reviewer's model comes from the first of these that sets one:
/council model gandalf opus). Each switch is checked with one tiny request first./config ("Gandalf model", "Legolas model", "Aragorn model", "Full council model": default, sonnet, opus, fable or haiku).models in the project rules file, which also takes full model IDs.The token cap, effort and deadline follow the model automatically. "Review deadline" in /config overrides the deadline.
/config)| Option | Default | | :- | :- | | Gandalf enabled | on (off: calls only it would review come to you) | | Gandalf model | default | | Legolas enabled | on (off: its reviews go to Gandalf) | | Legolas model | default | | Aragorn enabled | on (off: its reviews go to Gandalf) | | Aragorn model | default | | Full council for big operations | on (off: big operations go to one reviewer) | | Full council model | default | | Full council one at a time | off (members in parallel) | | Project checks enabled | on (runs gimli.commands for big operations) | | Review deadline (seconds) | 0 (from the model) | | Session token budget | 1,500,000 (spent: reviews come to you) | | Audit log path | .claude/council-of-elrond/audit/audit.jsonl | | Audit log size before rotation (KB) | 1024, three files kept | | Shadow mode | off | | Plain mode | off (themed). On: no theme text anywhere | | Secrets scan enabled | on | | Read-only preview enabled | on | | Preview line limit | 80 | | Diff line limit | 200 | | Tool errors count as failed attempts | on |
| Mode | What it does | How to set it | | :- | :- | :- | | Enforcing (the default) | Every tier applies. A reviewer's revise or block refuses the call; a reviewer that fails, or none being on, brings it to you. | Nothing to set. | | Shadow | Reviewers and the full council (its project checks included) log their verdicts and never refuse; a failed review passes, logged. The block tier, the ask tier, protected paths, the secrets scan and lockouts still enforce. | /council shadow on for the session, or "Shadow mode" in /config. Label: council: shadow. | | Bypass (themed: Leeroy mode) | Every gated call passes unreviewed, block-tier calls and the secrets scan included, and each is logged. | /council off, for this session only and never saved; /council on ends it. Label: council: bypass (council: Leeroy mode themed). | | Plain | Every string in its plain variant, with no theme text anywhere; behaviour is identical. It combines with any mode above. | "Plain mode" in /config. |
By default the council speaks in its theme. The "Plain mode" option in /config switches every theme word off; behaviour is identical in both modes.
What is themed, for you only:
/council, the dialog and the report say Gandalf, Legolas, Aragorn (git or databases), Gimli, Gollum, Galadriel, Elrond and the Council of Elrond where plain mode says the role ("the destructive-operations reviewer", "the full council").council: Leeroy mode by the prompt, and in /council)./council counts wipes where plain mode says "refused or failed attempts".What is never themed: what Claude reads. Refusals, the full council's reasons and the rule reason written to your rules file are plain in both modes, and name the role, not the character. Member ids you type (/council model gandalf opus) are config keys and stay as written. The dialogs that confirm exact data you agree to write (the allowlist entry, an allow rule) keep their plain text.
Each theme term and its plain name are in the Glossary.
"Council" stays in plain mode: it is the product's name.
Everything /council prints is for you only: it draws in a pane where a surface draws one, else as dim transcript lines. Claude never reads it. In a plain claude -p "/council" run nothing prints; use --output-format stream-json, where the lines arrive as ui_log messages.
| Command | What it does | | :- | :- | | /council | Status: mode, each member with its state, model and verdict counts, the full council and the project checks, failed attempts since your last prompt, tokens spent, median review time. | | /council off, /council on | Bypass for this session: gated calls pass unreviewed and are logged. Never persisted. The label council: bypass (council: Leeroy mode when themed) shows by the prompt. | | /council shadow on, /council shadow off | Shadow mode for this session, over the /config setting. | | /council log [n] | The last n gated calls (default 10) from the audit log. | | /council rules | Every effective rule with its source (shipped or project), and the lists. | | /council test "<command>" | Which tier, rule, reviewer (and profile, and why it fell back to Gandalf if it did) and operation key a shell command would get. For a big operation it shows the full council's seats, model and checks. Runs nothing, so a merge is assumed to land on a protected branch. | | /council model [<member> <model> [--save]] | Lists each slot's model and where it came from, or switches one for this session (default clears the switch). --save also writes the /config row when the model is one of its picker values. | | /council reload | Reads rules.json again. | | /council report | A summary of the audit log, rotated files included (see below). | | /council debate | Opens the debate pane at any width (where nothing draws, prints its rows as transcript lines). |
/council reportRead the report after a few days in shadow mode to tune the rules. It has four parts:
A line that doesn't parse is skipped and counted; a log file that can't be read is named and left out.
Three things show you a review as it happens, none of them read by Claude:
The debate themed, Council review plain) lists your newest reviews, three at most, newest first. Each shows the call, then one row per reviewer: its name, a symbol with a word (✓ approve, ✗ revise, ✗ block, ✗ no verdict, … reviewing, – sat out), its reason and its safer alternative, and, in themed mode, one line of flavour. A full council also shows each project check (✓ passed, ✗ failed, ✗ timed out, ✗ could not start, – stopped, … running) and a final verdict row. Below the reviews are two session-wide rows: the wipe counter (refused or failed attempts since your last prompt, over how many operations, and how many are locked out; "Wipes" themed) and the threat meter (each reviewer that has blocked, most blocks first, as a bar and the number: Gandalf ███ 3 blocks; plain mode says the destructive-operations reviewer ███ 3 blocks). Nothing depends on colour: every symbol has its word.The pane has its own tab (council-debate), beside the /council output pane. It opens on its own once per session, the first time a model review or a full council starts; Claude Code keeps an unasked pane hidden below 144 terminal columns, so on a narrow terminal it stays hidden. /council debate opens it at any width, and is the way back after you close it. It follows the agent in view: with a subagent's transcript on screen, it shows that subagent's reviews.
What draws where: the pane on every surface Claude Code draws panes for (terminal, desktop, VS Code, mobile; whether VS Code paints it is for a live check to say), and the band on the terminal and desktop only. Where a surface draws nothing (a claude -p run, the SDK, cloud), nothing is drawn and nothing is lost: /council debate prints the rows as transcript lines, and the audit log and /council keep the full record. The mod does not reach a cloud session from repository settings; see Cloud sessions.
Every call is scanned for secrets in what it would write or run: the shell command (heredocs included), Write content, an Edit's new text, a notebook cell, an MCP call's input. A high-confidence finding refuses the call whatever its tier, allowed calls included. A low-confidence finding comes to you only on a gated call: an allowed call never gets a question.
allow). Claude is told to remove the secret.password=…-style assignments, long random-looking tokens): comes to you with a redacted snippet. Allow once, keep blocked, type an instruction, or add to allowlist. The allowlist asks a second time, showing the exact entry: a sha256: fingerprint of the secret, never the secret. Only then is it written to rules.json under gollum.allowlist.Add your own patterns in rules.json:
{
"schemaVersion": 1,
"gollum": {
"patterns": [{ "id": "acme-key", "level": "high", "regex": "ACME-[0-9]{8}", "label": "Acme key" }],
"allowlist": ["sha256:0123456789abcdef"]
}
}
Allowlist entries are exact strings or fingerprints, never regexes. Your patterns are also used to redact the dialog, reviewer prompts and the audit log.
Before a review, the mod runs a few fixed, read-only inspections and shows the result to the reviewer and to you. The commands come from a table in the mod; the call's targets are passed only as data, and the proposed command never runs.
rm, find -delete): what each target is, and a folder's entries (no process).git push: the current branch, the remote's URL (redacted) and the commits it would send. When the remote does not have the branch yet, the commits are shown against the remote's default branch (<remote>/HEAD).git merge: the current branch and the commits it would hooks/register.ts 1636 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, ProcessRunResult, ProcessSpawnResult, Register, RenderElement, ToolCallResult } from 'claude-code'
3
4import type { CouncilPanel } from '../types'
5import { appendPlan, AUDIT_GITIGNORE, auditLine, fingerprintOf, ROTATED_FILES } from './audit.js'
6import type { AuditRecord, Decision } from './audit.js'
7import { OVERRIDES_PATH } from './config/defaults.js'
8import { loadConfig, MODEL_ID } from './config/schema.js'
9import type { LoadedConfig } from './config/schema.js'
10import { MODEL_SLOTS } from './config/types.js'
11import type { GimliCommand, MemberName, ModelSlot } from './config/types.js'
12import { withAllowlistEntry, withRule } from './config/write.js'
13import type { FileEdit } from './config/write.js'
14import { isSlot, logOutput, modelsOutput, parseCouncil, rulesOutput, statusOutput, testOutput } from './elrond/commands.js'
15import { combine, hasRealBlock } from './elrond/combine.js'
16import type { Voice } from './elrond/combine.js'
17import { bigOperationOf, councilSeats, needsCurrentBranch, rangeOf } from './elrond/council.js'
18import type { Big, CouncilSeat } from './elrond/council.js'
19import type { CouncilCommand, Output } from './elrond/commands.js'
20import { interpretAnswer, interpretRejection, optionsOf, questionText } from './elrond/escalation.js'
21import type { Answer, MemberOpinion, Unanswered } from './elrond/escalation.js'
22import { deadlineFor, profileOf, resolveModel } from './elrond/models.js'
23import type { ModelChoice } from './elrond/models.js'
24import {
25 cacheApprove,
26 isOutOfRounds,
27 isWipeOutcome,
28 lockoutOf,
29 noteRound,
30 noteWipe,
31 operationOf,
32 outcomeOf,
33 resetOperation,
34 resetRounds,
35 roundsLeft,
36} from './elrond/operations.js'
37import type { WipePolicy } from './elrond/operations.js'
38import { refusalText } from './elrond/refusal.js'
39import { reportOutput } from './elrond/report.js'
40import { bandRows, debateRows, voiceNote } from './elrond/view.js'
41import type { Row } from './elrond/view.js'
42import { ruleJson, suggestRule } from './elrond/suggest.js'
43import type { Refusal } from './elrond/refusal.js'
44import { route } from './elrond/routing.js'
45import type { Enabled, Seat } from './elrond/routing.js'
46import { ARAGORN_LIMITS, productionHits, sqlOf } from './members/aragorn.js'
47import type { SqlPiece } from './members/aragorn.js'
48import { requestOf, whoOf } from './members/brief.js'
49import type { Brief } from './members/brief.js'
50import { formatPreview, INSPECTION_TIMEOUT_MS, MAX_LISTED, planPreview, rangeDiffInspection } from './members/galadriel.js'
51import type { Inspection, InspectionResult } from './members/galadriel.js'
52import { GANDALF_LIMITS } from './members/gandalf.js'
53import { isGimliBlock, keepTail, statusOf, tailOf } from './members/gimli.js'
54import type { GimliRun } from './members/gimli.js'
55import { patternsWith, scanCall } from './members/gollum.js'
56import { callDiff, LEGOLAS_LIMITS } from './members/legolas.js'
57import type { CallDiff, Current } from './members/legolas.js'
58import type { GollumFinding } from './members/gollum.js'
59import { newNonce, parseVerdict, truncate } from './members/shared.js'
60import type { Verdict } from './members/shared.js'
61import { redact } from './redact.js'
62import type { SecretPattern } from './redact.js'
63import { classify, FILE_PATH_FIELDS, SHELL_TOOLS } from './rules/classify.js'
64import type { Call, Classification, ClassifyContext } from './rules/classify.js'
65import { isInside, relativeTo, resolve } from './rules/paths.js'
66import {
67 addReviewTime,
68 addTokens,
69 clearCache,
70 clearEpic,
71 closeDebate,
72 count,
73 declineRule,
74 INITIAL_SESSION,
75 isShadow,
76 markDebateOpened,
77 noteCheck,
78 noteVoice,
79 noteWritten,
80 openDebate,
81 resetForPrompt,
82 sessionOf,
83 tidyCall,
84 tidyReason,
85 withBypass,
86 withEpic,
87 withSessionModel,
88 withShadow,
89} from './state.js'
90import type { CouncilDebate, CouncilSession } from '../types'
91import { currentMode, setMode, text } from './strings.js'
92import type { StringKey } from './strings.js'
93
94/**
95 * Elrond, the chair: the one `tool.call` hook every call passes through.
96 * Rules classify it; allow passes untouched; block refuses; a locked-out
97 * operation refuses; the secrets scan refuses or asks; ask goes to the user;
98 * review gets a read-only preview and goes to a model member. Every failure
99 * refuses or asks: the hook never lets a gated call through on an error.
100 *
101 * Also `/council`, and the mode label by the prompt. The only file that
102 * touches `$`; everything it decides with is a pure function in a sibling.
103 */
104
105const session = atom({ plugin: 'council-of-elrond', key: 'session' } as const, INITIAL_SESSION)
106
107const panel = atom({ plugin: 'council-of-elrond', key: 'panel' } as const, { title: '', lines: [] } as CouncilPanel)
108
109/** Margin kept on the hook's own 10 s budget before any pass-through. */
110const BUDGET_GUARD_MS = 1_000
111
112/** Prompt origins that are the user's own and reset the per-prompt state. */
113const HUMAN_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
114
115const MAX_SCRIPTS = 3
116
117const COMMAND = 'council'
118const PANE_ID = 'council'
119
120/** The debate pane: its own id, so it sits beside the `/council` output as a tab. */
121const DEBATE_PANE_ID = 'council-debate'
122
123/** Rows the debate pane asks for where it is placed inline. */
124const DEBATE_PANE_ROWS = 12
125
126/** How long the epic drop row and toast last. */
127const EPIC_MS = 8_000
128
129/** The `/config` row a `--save` writes, per slot that has one. */
130const CONFIG_ROWS: Readonly<Partial<Record<ModelSlot, string>>> = {
131 gandalf: 'council-of-elrond.gandalfModel',
132 legolas: 'council-of-elrond.legolasModel',
133 aragorn: 'council-of-elrond.aragornModel',
134 council: 'council-of-elrond.councilModel',
135}
136
137/** What the `/config` model picker offers; anything else is session-only. */
138const PICKER_OPTIONS: readonly string[] = ['default', 'sonnet', 'opus', 'fable', 'haiku']
139
140/** Git for previews: no locks taken, no prompts, plain output. */
141const GIT_ENV: Record<string, string> = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat', LC_ALL: 'C' }
142
143const PROBE_TIMEOUT_MS = 20_000
144
145type Settings = {
146 gandalfEnabled: boolean
147 gandalfModel: string
148 legolasEnabled: boolean
149 legolasModel: string
150 aragornEnabled: boolean
151 aragornModel: string
152 councilEnabled: boolean
153 councilModel: string
154 councilSequential: boolean
155 gimliEnabled: boolean
156 diffLines: number
157 reviewDeadlineSeconds: number
158 tokenBudget: number
159 auditLogPath: string
160 auditMaxKb: number
161 shadowMode: boolean
162 toolErrorsAreWipes: boolean
163 previewLines: number
164 gollumEnabled: boolean
165 galadrielEnabled: boolean
166 plainMode: boolean
167}
168
169const modelSetting = (value: unknown): string => (typeof value === 'string' ? value : 'default')
170
171const settingsOf = (options: PluginOptions): Settings => ({
172 gandalfEnabled: options.gandalfEnabled !== false,
173 gandalfModel: modelSetting(options.gandalfModel),
174 legolasEnabled: options.legolasEnabled !== false,
175 legolasModel: modelSetting(options.legolasModel),
176 aragornEnabled: options.aragornEnabled !== false,
177 aragornModel: modelSetting(options.aragornModel),
178 councilEnabled: options.councilEnabled !== false,
179 councilModel: modelSetting(options.councilModel),
180 councilSequential: options.councilSequential === true,
181 gimliEnabled: options.gimliEnabled !== false,
182 diffLines: typeof options.diffLines === 'number' ? options.diffLines : 200,
183 reviewDeadlineSeconds: typeof options.reviewDeadlineSeconds === 'number' ? options.reviewDeadlineSeconds : 0,
184 tokenBudget: typeof options.tokenBudget === 'number' ? options.tokenBudget : 1_500_000,
185 auditLogPath:
186 typeof options.auditLogPath === 'string' && options.auditLogPath !== ''
187 ? options.auditLogPath
188 : '.claude/council-of-elrond/audit/audit.jsonl',
189 auditMaxKb: typeof options.auditMaxKb === 'number' ? options.auditMaxKb : 1024,
190 shadowMode: options.shadowMode === true,
191 toolErrorsAreWipes: options.toolErrorsAreWipes !== false,
192 previewLines: typeof options.previewLines === 'number' ? options.previewLines : 80,
193 gollumEnabled: options.gollumEnabled !== false,
194 galadrielEnabled: options.galadrielEnabled !== false,
195 plainMode: options.plainMode === true,
196})
197
198type Context = {
199 loaded: LoadedConfig
200 root: string
201 /** The root with symbolic links resolved, to map resolved paths back under `root`. */
202 realRoot: string
203 home?: string
204 /** Shipped and configured secret patterns: the scan's, and every redaction's. */
205 patterns: readonly SecretPattern[]
206}
207
208type Review =
209 | { ok: true; verdict: Verdict; model: string; tokens: number }
210 | { ok: false; problem: string; model: string; tokens: number }
211
212// Module state: starts over on every load, as the module does.
213let settings: Settings = settingsOf({})
214let context: Promise<Context> | undefined
215let auditQueue: Promise<void> = Promise.resolve()
216let isGitignoreChecked = false
217const warned = new Set<string>()
218
219const wipePolicy = (): WipePolicy => ({ toolErrors: settings.toolErrorsAreWipes })
220
221const enabledMembers = (): Enabled => ({
222 gandalf: settings.gandalfEnabled,
223 legolas: settings.legolasEnabled,
224 aragorn: settings.aragornEnabled,
225})
226
227/** The /config row's model for a slot. */
228const settingsModel = (slot: ModelSlot): string => {
229 switch (slot) {
230 case 'gandalf':
231 return settings.gandalfModel
232 case 'legolas':
233 return settings.legolasModel
234 case 'aragorn':
235 return settings.aragornModel
236 case 'council':
237 return settings.councilModel
238 }
239}
240
241function warnOnce($: EngineInterface, key: string, line: string, toast?: string): void {
242 if (warned.has(key)) return
243 warned.add(key)
244 $.ui.log(line)
245 if (toast !== undefined) $.ui.toast(toast, { timeoutMs: 8_000 })
246}
247
248async function loadContext($: EngineInterface): Promise<Context> {
249 const root = await $.session.root()
250 const realRoot = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
251 const home = await $.env.get('HOME')
252 const path = `${root}/${OVERRIDES_PATH}`
253 let fileText: string | undefined
254 let readProblem: string | undefined
255 if (await $.fs.exists(path)) {
256 fileText = await $.fs.read(path).catch((error: unknown) => {
257 readProblem = `(file): could not be read: ${error instanceof Error ? error.message : String(error)}`
258 return undefined
259 })
260 }
261 const loaded = readProblem !== undefined ? { ...loadConfig(undefined), errors: [readProblem] } : loadConfig(fileText)
262 if (loaded.errors.length > 0) {
263 warnOnce(
264 $,
265 'config',
266 text('notice.configBroken', { path: OVERRIDES_PATH, errors: loaded.errors.join('; ') }),
267 text('notice.configBrokenShort'),
268 )
269 }
270 const patterns = patternsWith(loaded.compiled.config.gollum.patterns)
271 return { loaded, root, realRoot, patterns, ...(home !== undefined && { home }) }
272}
273
274async function contextOf($: EngineInterface): Promise<Context> {
275 if (context === undefined) {
276 context = loadContext($).catch((error: unknown) => {
277 context = undefined
278 throw error
279 })
280 }
281 return context
282}
283
284/** Reads the rules again (decision 9: after the mod's own writes, and on `/council reload`). */
285async function reloadContext($: EngineInterface): Promise<Context> {
286 context = undefined
287 warned.delete('config')
288 const fresh = await contextOf($)
289 await update($, session, value => clearCache(sessionOf(value)))
290 return fresh
291}
292
293/** Where a file tool's path really lands, symbolic links resolved, mapped under the root. */
294async function realPathOf($: EngineInterface, call: Call, cwd: string, ctx: Context): Promise<string | undefined> {
295 const field = FILE_PATH_FIELDS[call.tool]
296 const given = field === undefined ? undefined : call.input[field]
297 if (typeof given !== 'string') return undefined
298 const absolute = resolve(given, cwd, ctx.home)
299 const cut = absolute.lastIndexOf('/')
300 const own = await $.fs.stat(absolute, { resolve: true }).catch(() => undefined)
301 let real = own?.realPath
302 if (real === undefined) {
303 const folder = await $.fs.stat(absolute.slice(0, cut) || '/', { resolve: true }).catch(() => undefined)
304 if (folder?.realPath !== undefined) real = `${folder.realPath.replace(/\/$/, '')}/${absolute.slice(cut + 1)}`
305 }
306 if (real === undefined) return undefined
307 return ctx.realRoot !== ctx.root && (real === ctx.realRoot || isInside(real, ctx.realRoot))
308 ? `${ctx.root}${real.slice(ctx.realRoot.length)}`
309 : real
310}
311
312/** Content of scripts the call runs that Claude wrote this session, redacted. */
313async function scriptsOf(
314 $: EngineInterface,
315 classification: Classification,
316 cwd: string,
317 written: readonly string[],
318 ctx: Context,
319): Promise<{ path: string; content: string }[]> {
320 const scripts: { path: string; content: string }[] = []
321 for (const word of classification.scriptPaths) {
322 if (scripts.length >= MAX_SCRIPTS) break
323 const path = resolve(word, cwd, ctx.home)
324 if (!written.includes(path)) continue
325 const content = await $.fs.read(path).catch(() => undefined)
326 if (typeof content === 'string') {
327 scripts.push({ path, content: redact(truncate(content, GANDALF_LIMITS.scriptLines, GANDALF_LIMITS.scriptChars), ctx.patterns) })
328 }
329 }
330 return scripts
331}
332
333/**
334 * Runs one git inspection from the table: its argv, then, only if that exits
335 * non-zero or throws and the table gives one, its fixed `orElse` argv, once.
336 * The fallback's result (and label) stands only if it succeeds; otherwise the
337 * first attempt's outcome is kept, as if there were no fallback.
338 */
339async function runGitInspection(
340 $: EngineInterface,
341 step: { label: string; argv: readonly string[]; orElse?: { label: string; argv: readonly string[] } },
342 ctx: Context,
343): Promise<{ label: string; run: ProcessRunResult }> {
344 const attempt = (argv: readonly string[]): Promise<ProcessRunResult> => $.process.run(argv, { cwd: ctx.root, env: GIT_ENV, timeoutMs: INSPECTION_TIMEOUT_MS })
345 let first: ProcessRunResult | undefined
346 let failure: unknown
347 try {
348 first = await attempt(step.argv)
349 if (first.exitCode === 0 || step.orElse === undefined) return { label: step.label, run: first }
350 } catch (error) {
351 if (step.orElse === undefined) throw error
352 failure = error
353 }
354 try {
355 const second = await attempt(step.orElse.argv)
356 if (second.exitCode === 0) return { label: step.orElse.label, run: second }
357 } catch {
358 // The fallback failing leaves the first attempt's outcome.
359 }
360 if (first === undefined) throw failure
361 return { label: step.label, run: first }
362}
363
364/**
365 * Runs Galadriel's inspections: only what the table planned, read-only, each
366 * on its own timeout. A failure is no preview, never a decision.
367 */
368async function previewOf($: EngineInterface, plan: readonly Inspection[], ctx: Context): Promise<string | undefined> {
369 const results: InspectionResult[] = []
370 for (const step of plan) {
371 try {
372 if (step.kind === 'path') {
373 const stat = await $.fs.stat(step.path).catch(() => undefined)
374 if (stat === undefined) {
375 results.push({ kind: 'path', label: step.label, state: 'missing' })
376 } else if (stat.kind === 'dir') {
377 const entries = await $.fs.list(step.path)
378 const names = entries.slice(0, MAX_LISTED).map(entry => (entry.kind === 'dir' ? `${entry.name}/` : entry.name))
379 results.push({ kind: 'path', label: step.label, state: 'dir', entries: names, total: entries.length })
380 } else {
381 results.push({ kind: 'path', label: step.label, state: 'file', size: stat.size })
382 }
383 } else {
384 const { label, run } = await runGitInspection($, step, ctx)
385 results.push({ kind: 'git', label, exitCode: run.exitCode, stdout: run.stdout.slice(0, 50_000) })
386 }
387 } catch {
388 results.push({ kind: 'failed', label: step.label })
389 }
390 }
391 const preview = formatPreview(results, settings.previewLines)
392 return preview === undefined ? undefined : redact(preview, ctx.patterns)
393}
394
395/**
396 * What a file tool would change, as a diff against the file as it stands,
397 * redacted. Reading the file is the only I/O; nothing runs.
398 */
399async function fileDiffOf($: EngineInterface, call: Call, cwd: string, realPath: string | undefined, ctx: Context): Promise<{ path: string; diff: CallDiff } | undefined> {
400 const field = FILE_PATH_FIELDS[call.tool]
401 const given = field === undefined ? undefined : call.input[field]
402 if (field === undefined) return undefined
403 if (typeof given !== 'string') return { path: '(no path)', diff: callDiff(call, '(no path)', { state: 'unreadable' }, settings.diffLines) }
404 const absolute = realPath ?? resolve(given, cwd, ctx.home)
405 const path = relativeTo(absolute, ctx.root) || absolute
406 let current: Current
407 if (!(await $.fs.exists(absolute).catch(() => true))) {
408 current = { state: 'missing' }
409 } else {
410 const content = await $.fs.read(absolute).catch(() => undefined)
411 current = typeof content === 'string' ? { state: 'text', text: content } : { state: 'unreadable' }
412 }
413 const diff = callDiff(call, path, current, settings.diffLines)
414 return { path, diff: { ...diff, diff: redact(diff.diff, ctx.patterns) } }
415}
416
417/** The `.sql` files a database call names, read when they sit inside the project, redacted and cut. */
418async function sqlFilesOf($: EngineInterface, files: readonly { word: string; cwd?: string }[], cwd: string, ctx: Context): Promise<SqlPiece[]> {
419 const pieces: SqlPiece[] = []
420 for (const file of files) {
421 const path = resolve(file.word.replace(/^['"]|['"]$/g, ''), file.cwd ?? cwd, ctx.home)
422 if (!isInside(path, ctx.root)) continue
423 // Inside by name is not enough: a link may land outside the project.
424 const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath
425 if (real === undefined || !isInside(real, ctx.realRoot)) continue
426 const content = await $.fs.read(real).catch(() => undefined)
427 if (typeof content === 'string') {
428 pieces.push({ label: relativeTo(path, ctx.root) ?? path, text: redact(truncate(content, ARAGORN_LIMITS.sqlLines, ARAGORN_LIMITS.sqlChars), ctx.patterns) })
429 }
430 }
431 return pieces
432}
433
434/** What the seated member is given: the context its profile asks for, and nothing else. */
435async function briefOf(
436 $: EngineInterface,
437 seat: Seat,
438 call: Call,
439 classification: Classification,
440 cwd: string,
441 realPath: string | undefined,
442 state: CouncilSession,
443 preview: string | undefined,
444 ctx: Context,
445): Promise<Brief> {
446 const ruleReasons = [...new Set(classification.findings.map(finding => finding.reason))]
447 const shown = callText(call, ctx.patterns)
448 const latestPrompt = state.latestPrompt
449 const withPreview = preview !== undefined ? { preview } : {}
450 // Routing seats the diff reviewer on file tools only, which always have a diff.
451 const file = seat.member === 'gandalf' || seat.profile === 'git' ? undefined : await fileDiffOf($, call, cwd, realPath, ctx)
452 if (seat.member === 'legolas' && file !== undefined) {
453 return { member: 'legolas', context: { tool: call.tool, path: file.path, diff: file.diff, ruleReasons, latestPrompt, ...withPreview } }
454 }
455 if (seat.member === 'aragorn' && seat.profile === 'git') {
456 return {
457 member: 'aragorn',
458 profile: 'git',
459 context: { tool: call.tool, call: shown, ruleReasons, latestPrompt, protectedBranches: ctx.loaded.compiled.config.protectedBranches, ...withPreview },
460 }
461 }
462 if (seat.member === 'aragorn') {
463 const found = sqlOf(classification)
464 const inline = found.inline.map(piece => ({ label: piece.label, text: redact(piece.text, ctx.patterns) }))
465 const sql = [...inline, ...(await sqlFilesOf($, found.files, cwd, ctx))]
466 const production = productionHits([shown, ...sql.map(piece => piece.text)].join('\n'), ctx.loaded.compiled.production)
467 return {
468 member: 'aragorn',
469 profile: 'database',
470 context: { tool: call.tool, call: shown, ruleReasons, latestPrompt, sql, production, ...(file !== undefined && { diff: file.diff }) },
471 }
472 }
473 const scripts = await scriptsOf($, classification, cwd, state.written, ctx)
474 return { member: 'gandalf', context: { tool: call.tool, call: shown, ruleReasons, latestPrompt, scripts, ...withPreview } }
475}
476
477/**
478 * One model review, for any member: its own system prompt and prompt, the
479 * model's request settings, a deadline passed as the call's own timeout,
480 * and the strict verdict parse. Every failure is a review without a verdict.
481 */
482async function review($: EngineInterface, brief: Brief, model: string, deadlineMs: number, signal: AbortSignal): Promise<Review> {
483 const nonce = newNonce()
484 const request = requestOf(brief, nonce)
485 const limits = profileOf(model)
486 const who = text(whoOf(brief.member, brief.profile))
487 try {
488 const reply = await $.model.complete(
489 {
490 model,
491 system: request.system,
492 prompt: request.prompt,
493 maxTokens: limits.maxTokens,
494 ...(limits.effort !== undefined && { effort: limits.effort }),
495 timeoutMs: deadlineMs,
496 },
497 { signal },
498 )
499 const usage = reply.usage
500 const tokens =
501 usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
502 if (!reply.isAnswered) {
503 const problem =
504 reply.reason === 'aborted'
505 ? 'it ran out of time or was interrupted'
506 : reply.reason === 'api-error'
507 ? `the API answered with an error (${reply.error})`
508 : 'it gave an empty reply'
509 if (reply.reason === 'api-error' && ['model_not_found', 'invalid_request', 'authentication_failed'].includes(reply.error)) {
510 warnOnce($, `model:${model}`, text('notice.modelFailed', { who, model, problem }))
511 }
512 return { ok: false, problem, model, tokens }
513 }
514 const parsed = parseVerdict(reply.text)
515 return parsed.ok
516 ? { ok: true, verdict: parsed.verdict, model, tokens }
517 : { ok: false, problem: `malformed verdict: ${parsed.problem}`, model, tokens }
518 } catch (error) {
519 const problem = `the request was refused: ${error instanceof Error ? error.message : String(error)}`
520 warnOnce($, `model:${model}`, text('notice.modelFailed', { who, model, problem }))
521 return { ok: false, problem, model, tokens: 0 }
522 }
523}
524
525/** The checked-out branch, for whether a merge lands on a protected one; unknown on any failure. */
526async function currentBranchOf($: EngineInterface, root: string): Promise<string | undefined> {
527 try {
528 const run = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, env: GIT_ENV, timeoutMs: INSPECTION_TIMEOUT_MS })
529 const branch = run.stdout.trim()
530 return run.exitCode === 0 && branch !== '' ? branch : undefined
531 } catch {
532 return undefined
533 }
534}
535
536/**
537 * What a push would send or a merge bring in, for the diff reviewer: one
538 * read-only `git diff` from Galadriel's table, cut to the diff limit and
539 * redacted, with the range that was read (the table's fallback range when the
540 * first can't be read). Undefined when git can't say.
541 */
542async function rangeDiffOf($: EngineInterface, step: Extract<Inspection, { kind: 'git' }>, ctx: Context): Promise<{ label: string; diff: string } | undefined> {
543 try {
544 const { label, run } = await runGitInspection($, step, ctx)
545 if (run.exitCode !== 0) return undefined
546 const diff = run.stdout.slice(0, LEGOLAS_LIMITS.diffChars * 4).trimEnd()
547 return { label, diff: redact(truncate(diff === '' ? '(no changes)' : diff, settings.diffLines, LEGOLAS_LIMITS.diffChars), ctx.patterns) }
548 } catch {
549 return undefined
550 }
551}
552
553/**
554 * Runs one of the project's checks from the rules file, by argument vector,
555 * in the project root. Its own timeout ends it (a clock timer, which costs
556 * the hook no budget while the child's output is awaited); so does `stop`
557 * once the council has blocked, and Esc, through the dispatch's signal.
558 */
559async function runCheck($: EngineInterface, command: GimliCommand, root: string, stop: AbortSignal): Promise<GimliRun> {
560 const started = await $.clock.now()
561 let ended: 'timeout' | 'stopped' | undefined
562 let exit: ProcessSpawnResult | undefined
563 let output = ''
564 let isEnded: () => void = () => undefined
565 const endedEarly = new Promise<void>(resolve => {
566 isEnded = resolve
567 })
568 const stream = $.process.spawn({ argv: command.argv, cwd: root })
569 const end = (why: 'timeout' | 'stopped'): void => {
570 if (ended !== undefined || exit !== undefined) return
571 ended = why
572 isEnded()
573 // Ending the loop is what kills the child.
574 void stream.return(undefined as never).catch(() => undefined)
575 }
576 const timer = $.clock.after(command.timeoutMs, () => end('timeout'))
577 const onStop = (): void => end('stopped')
578 stop.addEventListener('abort', onStop)
579 if (stop.aborted) onStop()
580 const reading = (async () => {
581 for await (const chunk of stream) output = keepTail(output, chunk.text)
582 if (ended === undefined) exit = await stream.result
583 })().catch(() => {
584 // It could not start, or its stream broke: no exit code, so it does not pass.
585 })
586 try {
587 // Ended early, the run is over even if the stream is slow to close.
588 await Promise.race([reading, endedEarly])
589 } finally {
590 timer.cancel()
591 stop.removeEventListener('abort', onStop)
592 }
593 const ms = (await $.clock.now()) - started
594 return {
595 name: command.name,
596 status: statusOf(ended, exit),
597 ...(ended === undefined && exit !== undefined && { code: exit.code, signal: exit.signal }),
598 tail: tailOf(output),
599 ms,
600 }
601}
602
603/** One seat at the full council: who, and the brief it reviews (none: it sits out, saying why). */
604type Sitting = { seat: CouncilSeat; who: StringKey; brief?: Brief; skip?: string }
605
606type Held = { voices: Voice[]; tokens: number[]; runs: GimliRun[] }
607
608/** What `convene` reports as it goes, for the debate record: a voice resolved, or a check ended. */
609type Progress = { kind: 'voice'; index: number; voice: Voice } | { kind: 'check'; run: GimliRun }
610
611/**
612 * The full council. The project's checks start first and run on their own
613 * timeouts (decision 3); the model members run in parallel, or one at a time
614 * stopping at the first block, under one shared deadline passed to each
615 * request as the time remaining. Each member sees only its own brief.
616 */
617async function convene(
618 $: EngineInterface,
619 sittings: readonly Sitting[],
620 model: string,
621 deadlineMs: number,
622 isSequential: boolean,
623 checks: readonly GimliCommand[],
624 root: string,
625 signal: AbortSignal,
626 progress: (event: Progress) => Promise<void>,
627): Promise<Held> {
628 const started = await $.clock.now()
629 const stop = new AbortController()
630 let isCheckFailed = false
631 // The debate record is only watching: a note that fails or lags changes nothing here.
632 const noted: Promise<void>[] = []
633 const note = (event: Progress): void => {
634 noted.push(Promise.resolve().then(() => progress(event)).catch(() => undefined))
635 }
636 const checking = Promise.all(
637 checks.map(command =>
638 runCheck($, command, root, stop.signal).then(run => {
639 if (isGimliBlock(run)) {
640 isCheckFailed = true
641 stop.abort()
642 }
643 note({ kind: 'check', run })
644 return run
645 }),
646 ),
647 )
648 const tokens: number[] = sittings.map(() => 0)
649 const baseOf = (sitting: Sitting) => ({ who: sitting.who, member: sitting.seat.member, ...(sitting.seat.member === 'aragorn' && { profile: sitting.seat.profile }) })
650 const ask = async (sitting: Sitting, index: number): Promise<Voice> => {
651 const base = baseOf(sitting)
652 if (sitting.brief === undefined) return { kind: 'skipped', ...base, why: sitting.skip ?? '' }
653 const remaining = Math.floor(deadlineMs - ((await $.clock.now()) - started))
654 if (remaining < 1) return { kind: 'failed', ...base, problem: text('council.deadline') }
655 const result = await review($, sitting.brief, model, remaining, signal)
656 tokens[index] = result.tokens
657 return result.ok ? { kind: 'verdict', ...base, verdict: result.verdict } : { kind: 'failed', ...base, problem: result.problem }
658 }
659 const resolved = (index: number, voice: Voice): Voice => {
660 note({ kind: 'voice', index, voice })
661 return voice
662 }
663 let voices: Voice[]
664 if (isSequential) {
665 voices = []
666 for (const [index, sitting] of sittings.entries()) {
667 const isBlocked = isCheckFailed || voices.some(voice => voice.kind === 'failed' || (voice.kind === 'verdict' && voice.verdict.verdict === 'block'))
668 voices.push(
669 resolved(index, isBlocked ? { kind: 'skipped', ...baseOf(sitting), why: text('council.stopped') } : await ask(sitting, index)),
670 )
671 }
672 } else {
673 voices = await Promise.all(sittings.map(async (sitting, index) => resolved(index, await ask(sitting, index))))
674 }
675 // A check can only add a block: once a member has blocked, one still running cannot matter.
676 if (hasRealBlock(voices)) stop.abort()
677 const runs = await checking
678 await Promise.all(noted)
679 return { voices, tokens, runs }
680}
681
682/** Puts the call to the user; where nobody can be asked, says so. */
683async function escalate($: EngineInterface, question: string, withAllowlist: boolean): Promise<Answer | Unanswered> {
684 const surfaces = await $.session.surfaces().catch(() => [])
685 if (surfaces.length === 0) return 'unavailable'
686 try {
687 // One mode for the labels offered and the labels compared: they must match exactly.
688 const mode = currentMode()
689 const answer = await $.ui.ask(question, { options: optionsOf(mode, withAllowlist), header: text('ask.rollHeader', {}, mode) })
690 return interpretAnswer(answer, mode, withAllowlist)
691 } catch (error) {
692 return interpretRejection(error)
693 }
694}
695
696/** The second confirm before an allowlist entry is written: only "Add it" adds. */
697async function confirmAllowlist($: EngineInterface, finding: GollumFinding): Promise<boolean> {
698 const question = text('ask.confirmAllowlist', { label: finding.label, entry: finding.fingerprint, path: OVERRIDES_PATH, field: 'gollum.allowlist' })
699 try {
700 const answer = await $.ui.ask(question, { options: [text('ask.confirmAdd'), text('ask.confirmCancel')], header: text('ask.header') })
701 return answer === text('ask.confirmAdd')
702 } catch {
703 return false
704 }
705}
706
707/**
708 * Rewrites the project rules file with one confirmed change, read fresh: a
709 * broken file is left alone, and a result that doesn't validate isn't written.
710 */
711async function writeOverrides($: EngineInterface, root: string, change: (current: string | undefined) => FileEdit): Promise<FileEdit> {
712 const path = `${root}/${OVERRIDES_PATH}`
713 try {
714 const current = (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
715 const edit = change(current)
716 if (edit.ok) await $.fs.write(path, edit.text)
717 return edit
718 } catch (error) {
719 return { ok: false, problem: error instanceof Error ? error.message : String(error) }
720 }
721}
722
723/**
724 * After "allow once" and a call that ran: offers the allow rule for it, showing
725 * the exact JSON, and writes it only on "Add the rule" (then reloads, decision
726 * 9). Any other answer, or dismissing, declines it for the session. Returns the
727 * id of the rule written.
728 */
729async function offerRule($: EngineInterface, call: Call, classification: Classification, where: ClassifyContext, ctx: Context, hadSecret: boolean): Promise<string | undefined> {
730 const state = sessionOf(await read($, session))
731 const suggested = suggestRule(call, classification, ctx.loaded.compiled, where, { hadSecret, declined: state.declinedRules })
732 if (suggested.kind === 'none') return undefined
733 const { rule, key } = suggested.suggestion
734 if ((await $.session.surfaces().catch(() => [])).length === 0) return undefined
735 const question = text('ask.suggestRule', { path: OVERRIDES_PATH, json: ruleJson(rule) })
736 const answer = await $.ui
737 .ask(question, { options: [text('ask.suggestAdd'), text('ask.suggestDecline')], header: text('ask.header') })
738 .catch(() => undefined)
739 if (answer !== text('ask.suggestAdd')) {
740 await update($, session, value => declineRule(sessionOf(value), key))
741 return undefined
742 }
743 const written = await writeOverrides($, ctx.root, current => withRule(current, rule))
744 if (!written.ok) {
745 $.ui.log(text('notice.ruleFailed', { problem: written.problem }))
746 return undefined
747 }
748 await reloadContext($)
749 $.ui.log(text('notice.ruleWritten', { id: rule.id, path: OVERRIDES_PATH }))
750 return rule.id
751}
752
753async function appendAudit($: EngineInterface, record: AuditRecord, root: string): Promise<void> {
754 const path = resolve(settings.auditLogPath, root)
755 const folder = path.slice(0, path.lastIndexOf('/'))
756 if (!isGitignoreChecked) {
757 isGitignoreChecked = true
758 const ignore = `${folder}/.gitignore`
759 if (isInside(path, root) && !(await $.fs.exists(ignore))) await $.fs.write(ignore, AUDIT_GITIGNORE)
760 }
761 const line = auditLine(record)
762 const current = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
763 const maxBytes = settings.auditMaxKb * 1024
764 const older: (string | undefined)[] = []
765 if (current !== '' && current.length + line.length > maxBytes) {
766 for (let n = 1; n < ROTATED_FILES - 1; n++) {
767 const rotated = `${path}.${n}`
768 older.push((await $.fs.exists(rotated)) ? await $.fs.read(rotated) : undefined)
769 }
770 }
771 for (const write of appendPlan(path, current, line, maxBytes, older)) await $.fs.write(write.path, write.text)
772}
773
774/** Appends one audit line; writes are serialized, and a failed write never changes a decision. */
775async function audit($: EngineInterface, record: AuditRecord, root: string): Promise<void> {
776 const queued = auditQueue.then(() => appendAudit($, record, root))
777 auditQueue = queued.catch((error: unknown) => {
778 $.ui.log(`Council: audit write failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
779 })
780 await auditQueue
781}
782
783/** Notes a file Claude wrote, once the write has run. */
784async function noteWrite($: EngineInterface, call: Call, cwd: string, home: string | undefined, result: ToolCallResult): Promise<void> {
785 const field = FILE_PATH_FIELDS[call.tool]
786 const given = field === undefined ? undefined : call.input[field]
787 const isWritten = typeof given === 'string' && result.deny === undefined && result.isError !== true
788 if (isWritten) await update($, session, value => noteWritten(sessionOf(value), resolve(given, cwd, home)))
789}
790
791/** The label by the prompt where SessionMode is not drawn (other surfaces): the status line. */
792async function syncIndicator($: EngineInterface): Promise<void> {
793 const state = sessionOf(await read($, session))
794 const label = state.bypass ? text('mode.bypass') : isShadow(state, settings.shadowMode) ? text('mode.shadow') : undefined
795 const surfaces = await $.session.surfaces().catch(() => [])
796 if (surfaces.some(surface => surface !== 'terminal' && surface !== 'desktop')) $.ui.status(label)
797}
798
799/** Shows `/council` output to the user: in its pane where one draws, else as transcript lines. Never to Claude. */
800async function show($: EngineInterface, output: Output): Promise<void> {
801 const surfaces = await $.session.surfaces().catch(() => [])
802 if (surfaces.length > 0) {
803 await update($, panel, () => output)
804 const opened = await $.ui.open({ id: PANE_ID, title: output.title }).catch(() => undefined)
805 if (opened?.isPlaced === true) return
806 }
807 $.ui.log(output.title)
808 for (const line of output.lines) $.ui.log(line)
809}
810
811/** Rows as a column of Text: a theme key for colour, never a raw colour, so a theme change reaches them. */
812function drawRows(Box: Parameters<typeof h>[0], Text: Parameters<typeof h>[0], rows: readonly Row[], wrap: 'wrap' | 'truncate'): RenderElement {
813 const lines = rows.map(row =>
814 h(Text, { wrap, ...(row.color !== undefined && { color: row.color }), ...(row.bold === true && { bold: true }), ...(row.dim === true && { dimColor: true }) }, row.text),
815 )
816 return h(Box, { flexDirection: 'column' }, ...lines) as RenderElement
817}
818
819/**
820 * Opens the debate pane unasked, once per session: the engine itself keeps an
821 * unasked open undrawn below 144 columns, so the mod never measures the terminal.
822 * A refused open is ignored; it is never retried.
823 */
824async function openDebatePane($: EngineInterface): Promise<void> {
825 if (sessionOf(await read($, session)).debateOpened) return
826 // Claimed before the open, so a parallel call does not open it too.
827 await update($, session, value => markDebateOpened(sessionOf(value)))
828 if ((await $.session.surfaces().catch(() => [])).length === 0) return
829 await $.ui.open({ id: DEBATE_PANE_ID, title: text('debate.title'), rows: DEBATE_PANE_ROWS }).catch(() => undefined)
830}
831
832/** `/council debate`: the pane at any width (it answers the person's command), else the same rows as transcript lines. */
833async function showDebate($: EngineInterface): Promise<void> {
834 const surfaces = await $.session.surfaces().catch(() => [])
835 if (surfaces.length > 0) {
836 const opened = await $.ui.open({ id: DEBATE_PANE_ID, title: text('debate.title'), rows: DEBATE_PANE_ROWS }).catch(() => undefined)
837 if (opened?.isPlaced === true) return
838 }
839 $.ui.log(text('debate.title'))
840 for (const row of debateRows(sessionOf(await read($, session)), undefined, currentMode())) $.ui.log(row.text)
841}
842
843/**
844 * The epic drop (themed mode, a pushed or merged call the full council
845 * approved): the row hides itself once `epicUntil` passes, whatever happens to
846 * the timer, which is only a redraw trigger (a hot reload cancels it).
847 */
848async function epicDrop($: EngineInterface): Promise<void> {
849 const until = (await $.clock.now()) + EPIC_MS
850 await update($, session, value => withEpic(sessionOf(value), until))
851 $.ui.toast(text('epic.toast'), { timeoutMs: EPIC_MS })
852 $.clock.after(EPIC_MS + 100, () => {
853 void (async () => {
854 try {
855 const now = await $.clock.now()
856 await update($, session, value => clearEpic(sessionOf(value), now))
857 } catch {
858 // The row hides itself at its time anyway.
859 }
860 })()
861 })
862}
863
864/** `/council report`: the rotated files (oldest first), then the current one; an unreadable file is named and left out. */
865async function reportFrom($: EngineInterface, path: string): Promise<Output> {
866 const files: string[] = []
867 const unreadable: string[] = []
868 for (const file of [...Array.from({ length: ROTATED_FILES - 1 }, (_, i) => `${path}.${ROTATED_FILES - 1 - i}`), path]) {
869 try {
870 if (await $.fs.exists(file)) files.push(await $.fs.read(file))
871 } catch (error) {
872 unreadable.push(`${file}: ${error instanceof Error ? error.message : String(error)}`)
873 }
874 }
875 return reportOutput(files, unreadable)
876}
877
878/** A one-request check that a model answers: an API error is a no, a timeout unsure. */
879async function probeModel($: EngineInterface, model: string): Promise<{ ok: true } | { ok: false; isCertain: boolean; problem: string }> {
880 try {
881 const reply = await $.model.complete({ model, prompt: 'Reply with the single word OK.', maxTokens: 16, timeoutMs: PROBE_TIMEOUT_MS })
882 const usage = reply.usage
883 await update($, session, value => addTokens(sessionOf(value), usage.input_tokens + usage.output_tokens))
884 if (reply.isAnswered || reply.reason === 'empty-reply') return { ok: true }
885 if (reply.reason === 'api-error') return { ok: false, isCertain: true, problem: reply.error }
886 return { ok: false, isCertain: false, problem: 'no answer in time' }
887 } catch (error) {
888 return { ok: false, isCertain: true, problem: error instanceof Error ? error.message : String(error) }
889 }
890}
891
892function modelChoice(slot: ModelSlot, ctx: Context, sessionModels: Readonly<Partial<Record<ModelSlot, string>>>): ModelChoice {
893 const project = ctx.loaded.compiled.config.models[slot]
894 const sessionModel = sessionModels[slot]
895 return resolveModel(slot, {
896 settings: settingsModel(slot),
897 ...(project !== undefined && { project }),
898 ...(sessionModel !== undefined && { session: sessionModel }),
899 })
900}
901
902async function councilModel($: EngineInterface, command: Extract<CouncilCommand, { kind: 'model' }>): Promise<Output> {
903 const title = text('cmd.title')
904 if (!isSlot(command.slot)) return { title, lines: [text('cmd.modelBadSlot', { slot: command.slot, slots: MODEL_SLOTS.join(', ') })] }
905 const slot = command.slot
906 const ctx = await contextOf($)
907 const before = sessionOf(await read($, session))
908 if (command.model === 'default') {
909 const after = await update($, session, value => withSessionModel(sessionOf(value), slot, undefined))
910 const now = modelChoice(slot, ctx, after.models)
911 return { title, lines: [text('cmd.modelCleared', { id: slot, model: now.model, source: now.source })] }
912 }
913 if (!MODEL_ID.test(command.model)) return { title, lines: [text('cmd.modelBadId', { model: command.model })] }
914
915 const lines: string[] = []
916 const probe = await probeModel($, command.model)
917 if (!probe.ok && probe.isCertain) {
918 const previous = modelChoice(slot, ctx, before.models)
919 return { title, lines: [text('cmd.modelProbeFailed', { model: command.model, problem: probe.problem, id: slot, previous: previous.model })] }
920 }
921 await update($, session, value => withSessionModel(sessionOf(value), slot, command.model))
922 lines.push(text('cmd.modelSet', { id: slot, model: command.model }))
923 if (!probe.ok) lines.push(text('cmd.modelProbeUnsure', { model: command.model, problem: probe.problem }))
924 if (command.save) {
925 const row = CONFIG_ROWS[slot]
926 if (row === undefined) {
927 lines.push(text('cmd.modelNotSaved', { problem: text('cmd.modelNoRow', { id: slot }) }))
928 } else if (!PICKER_OPTIONS.includes(command.model)) {
929 lines.push(text('cmd.modelNotSaved', { problem: text('cmd.modelNotOption', { options: PICKER_OPTIONS.join(', ') }) }))
930 } else {
931 const saved = await $.config.set({ key: row, value: command.model }).catch((error: unknown) => ({
932 deny: error instanceof Error ? error.message : String(error),
933 }))
934 lines.push(saved.deny === undefined ? text('cmd.modelSaved') : text('cmd.modelNotSaved', { problem: saved.deny }))
935 }
936 }
937 return { title, lines }
938}
939
940async function councilOutput($: EngineInterface, command: Exclude<CouncilCommand, { kind: 'debate' }>): Promise<Output> {
941 const title = text('cmd.title')
942 switch (command.kind) {
943 case 'status': {
944 const ctx = await contextOf($)
945 const state = sessionOf(await read($, session))
946 const mode = state.bypass ? 'bypass' : isShadow(state, settings.shadowMode) ? 'shadow' : 'enforcing'
947 return statusOutput({
948 session: state,
949 mode,
950 members: {
951 gandalf: { enabled: settings.gandalfEnabled, choice: modelChoice('gandalf', ctx, state.models) },
952 legolas: { enabled: settings.legolasEnabled, choice: modelChoice('legolas', ctx, state.models) },
953 aragorn: { enabled: settings.aragornEnabled, choice: modelChoice('aragorn', ctx, state.models) },
954 },
955 council: { enabled: settings.councilEnabled, choice: modelChoice('council', ctx, state.models), sequential: settings.councilSequential },
956 gimli: { enabled: settings.gimliEnabled, commands: ctx.loaded.compiled.config.gimli.commands.length },
957 gollumEnabled: settings.gollumEnabled,
958 galadrielEnabled: settings.galadrielEnabled,
959 tokenBudget: settings.tokenBudget,
960 })
961 }
962 case 'bypass':
963 await update($, session, value => withBypass(sessionOf(value), command.on))
964 await syncIndicator($)
965 return { title, lines: [text(command.on ? 'cmd.bypassOn' : 'cmd.bypassOff')] }
966 case 'shadow':
967 await update($, session, value => withShadow(sessionOf(value), command.on))
968 await syncIndicator($)
969 return { title, lines: [text(command.on ? 'cmd.shadowOn' : 'cmd.shadowOff')] }
970 case 'log': {
971 const ctx = await contextOf($)
972 const path = resolve(settings.auditLogPath, ctx.root)
973 try {
974 const logText = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
975 return logOutput(logText, command.count)
976 } catch (error) {
977 return { title, lines: [text('cmd.logUnreadable', { problem: error instanceof Error ? error.message : String(error) })] }
978 }
979 }
980 case 'rules': {
981 const ctx = await contextOf($)
982 return rulesOutput(ctx.loaded.compiled, ctx.loaded.origin, ctx.loaded.errors)
983 }
984 case 'test': {
985 // Classification only: nothing the command names is run, read or stat'ed.
986 const ctx = await contextOf($)
987 const cwd = await $.session.cwd()
988 const call: Call = { tool: 'Bash', input: { command: command.command } }
989 const where = { root: ctx.root, cwd, ...(ctx.home !== undefined && { home: ctx.home }) }
990 const classification = classify(call, ctx.loaded.compiled, where)
991 const operation = classification.tier === 'allow' ? undefined : operationOf(call, classification, where)
992 const state = sessionOf(await read($, session))
993 const seated = classification.tier === 'review' ? route(call, classification, enabledMembers()) : undefined
994 const reviewer =
995 seated === undefined
996 ? undefined
997 : { route: seated, ...(seated.kind === 'member' && { choice: modelChoice(seated.member, ctx, state.models) }) }
998 // The current branch is not read: a merge counts as one into a protected branch.
999 const big = bigOperationOf(classification, ctx.loaded.compiled)
1000 const council =
1001 big === undefined
1002 ? undefined
1003 : {
1004 big,
1005 enabled: settings.councilEnabled,
1006 seats: councilSeats(call, classification, enabledMembers(), { ranges: settings.galadrielEnabled }),
1007 choice: modelChoice('council', ctx, state.models),
1008 checks: settings.gimliEnabled ? ctx.loaded.compiled.config.gimli.commands.map(check => check.name) : [],
1009 isBranchAssumed: big.entry === 'merge-to-protected' && needsCurrentBranch(classification, ctx.loaded.compiled),
1010 }
1011 return testOutput(redact(command.command, ctx.patterns), classification, operation, reviewer, council)
1012 }
1013 case 'models': {
1014 const ctx = await contextOf($)
1015 const state = sessionOf(await read($, session))
1016 const choices = Object.fromEntries(MODEL_SLOTS.map(slot => [slot, modelChoice(slot, ctx, state.models)])) as Record<ModelSlot, ModelChoice>
1017 return modelsOutput(choices)
1018 }
1019 case 'model':
1020 return councilModel($, command)
1021 case 'reload': {
1022 const ctx = await reloadContext($)
1023 const lines = [text('cmd.reloaded', { count: ctx.loaded.compiled.rules.length, origin: ctx.loaded.origin })]
1024 if (ctx.loaded.errors.length > 0) lines.push(text('cmd.configErrors', { errors: ctx.loaded.errors.join('; ') }))
1025 return { title, lines }
1026 }
1027 case 'report': {
1028 const ctx = await contextOf($)
1029 return reportFrom($, resolve(settings.auditLogPath, ctx.root))
1030 }
1031 case 'usage':
1032 return { title, lines: [text(command.key, command.params ?? {}), ...(command.key === 'cmd.unknown' ? [text('cmd.help')] : [])] }
1033 }
1034}
1035
1036/** The call's arguments as text for a reviewer or the user: redacted, never whole files. */
1037function callText(call: Call, patterns: readonly SecretPattern[]): string {
1038 if (SHELL_TOOLS.has(call.tool) && typeof call.input.command === 'string') return redact(call.input.command, patterns)
1039 const field = FILE_PATH_FIELDS[call.tool]
1040 if (field !== undefined) {
1041 const { [field]: path, ...rest } = call.input
1042 return redact(`${String(path)}\n${JSON.stringify(rest, null, 1)}`, patterns)
1043 }
1044 return redact(JSON.stringify(call.input, null, 1), patterns)
1045}
1046
1047function callOf(e: Readonly<Record<string, unknown>>): Call {
1048 const { tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...input } = e
1049 return { tool: String(tool), input }
1050}
1051
1052export const register: Register = (on, options) => {
1053 settings = settingsOf(options)
1054 setMode(settings.plainMode ? 'plain' : 'themed')
1055
1056 on('session.start', async ($, e, next) => {
1057 const ctx = await contextOf($).catch(() => undefined)
1058 await $.command
1059 .register({
1060 name: COMMAND,
1061 description: 'The council: status, bypass, shadow mode, log, rules, test a command, models, reload, report, debate pane',
1062 argumentHint: '[on|off|shadow on|off|log [n]|rules|test "<cmd>"|model [<member> <model> [--save]]|reload|report|debate]',
1063 })
1064 .catch((error: unknown) => {
1065 $.ui.log(text('notice.commandFailed', { problem: error instanceof Error ? error.message : String(error) }))
1066 })
1067 // Decision 6: suggest shadow mode once, while the council has logged nothing.
1068 if (ctx !== undefined && !isShadow(sessionOf(await read($, session)), settings.shadowMode)) {
1069 const isEmpty = !(await $.fs.exists(resolve(settings.auditLogPath, ctx.root)).catch(() => true))
1070 if (isEmpty) warnOnce($, 'shadow-suggest', text('notice.shadowSuggest'), text('notice.shadowSuggest'))
1071 }
1072 await syncIndicator($).catch(() => undefined)
1073 return next(e)
1074 })
1075
1076 on('prompt.submit', async ($, e, next) => {
1077 if (HUMAN_ORIGINS.has(e.origin.kind)) {
1078 const patterns = (await contextOf($).catch(() => undefined))?.patterns
1079 await update($, session, value => resetForPrompt(sessionOf(value), redact(e.text, patterns)))
1080 }
1081 return next(e)
1082 })
1083
1084 // Output goes to the user only (decision 1): no text for Claude to read.
1085 on('command.run', { command: COMMAND }, async ($, e) => {
1086 const command = parseCouncil(e.args)
1087 if (command.kind === 'debate') await showDebate($)
1088 else await show($, await councilOutput($, command))
1089 return {}
1090 }).catch(($, e) => {
1091 $.ui.log(text('cmd.help'))
1092 return {}
1093 })
1094
1095 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
1096 const { Box, Text } = $.ui.resolve(e)
1097 const shown = await read($, panel)
1098 // Called directly rather than as JSX, so this module stays a .ts file.
1099 return h(Box, { flexDirection: 'column' }, ...shown.lines.map(line => h(Text, { wrap: 'wrap' }, line))) as RenderElement
1100 })
1101
1102 // The debate pane draws the agent in view (the main conversation: none) and only reads state.
1103 on('ui.render', { component: 'Pane', requestId: DEBATE_PANE_ID }, async ($, e) => {
1104 const { Box, Text } = $.ui.resolve(e)
1105 const state = sessionOf(await read($, session))
1106 return drawRows(Box, Text, debateRows(state, e.props.view.agentId, currentMode()), 'wrap')
1107 })
1108
1109 // The council check, and the epic drop: a row each, only while there is one (terminal and desktop).
1110 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1111 if (e.props.hasSurvey) return next(e)
1112 const state = sessionOf(await read($, session))
1113 const rows = bandRows(state, e.props.view.agentId, await $.clock.now(), currentMode())
1114 if (rows === undefined) return next(e)
1115 const { Box, Text } = $.ui.resolve(e)
1116 return drawRows(Box, Text, rows, 'truncate')
1117 })
1118
1119 // The mode label by the prompt while bypass or shadow is on (terminal and desktop).
1120 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1121 const state = sessionOf(await read($, session))
1122 const label = state.bypass ? text('mode.bypass') : isShadow(state, settings.shadowMode) ? text('mode.shadow') : undefined
1123 return label === undefined ? next(e) : next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
1124 })
1125
1126 on('tool.call', async ($, e, next) => {
1127 const started = Date.now()
1128 const call = callOf(e as unknown as Readonly<Record<string, unknown>>)
1129
1130 /** The one way a call passes: never with the hook's own budget spent. */
1131 const proceed = async (): Promise<ToolCallResult> =>
1132 next.budget.remainingMs < BUDGET_GUARD_MS
1133 ? { deny: refusalText({ who: 'who.council', verdict: 'error', reason: text('reason.internal'), alternative: text('alternative.ask') }) }
1134 : next(e)
1135
1136 const ctx = await contextOf($)
1137 const cwd = await $.session.cwd()
1138 const realPath = await realPathOf($, call, cwd, ctx)
1139 const where = {
1140 root: ctx.root,
1141 cwd,
1142 ...(ctx.home !== undefined && { home: ctx.home }),
1143 ...(realPath !== undefined && { realPath }),
1144 }
1145 const classification = classify(call, ctx.loaded.compiled, where)
1146
1147 if (classification.tier === 'allow') {
1148 // High-confidence secrets refuse an allowed call too (DESIGN §13.2). It has
1149 // no operation, so no rounds, no failed attempt and no state write; low
1150 // findings are ignored here, as the mod never asks on an allowed call.
1151 if (settings.gollumEnabled) {
1152 const scan = await scanCall(call, ctx.patterns, ctx.loaded.compiled.config.gollum.allowlist)
1153 const first = scan.high[0]
1154 if (first !== undefined) {
1155 const deny = refusalText({ who: 'who.gollum', verdict: 'block', reason: text('reason.secretHigh', { label: first.label, snippet: first.snippet }), alternative: text('alternative.removeSecret') })
1156 await audit(
1157 $,
1158 {
1159 ts: new Date().toISOString(),
1160 tool: call.tool,
1161 fingerprint: await fingerprintOf(call.tool, call.input),
1162 opKey: null,
1163 tier: 'allow',
1164 ruleId: classification.decided?.ruleId ?? null,
1165 member: 'gollum',
1166 profile: null,
1167 model: null,
1168 verdict: 'block',
1169 reason: scan.high.map(finding => finding.label).join(', '),
1170 shadow: false,
1171 bypass: false,
1172 decision: null,
1173 outcome: 'refused',
1174 latencyMs: Date.now() - started,
1175 tokens: 0,
1176 ...(e.agentId !== undefined && { agentId: e.agentId }),
1177 },
1178 ctx.root,
1179 )
1180 return { deny }
1181 }
1182 }
1183 const result = await proceed()
1184 await noteWrite($, call, cwd, ctx.home, result).catch(() => undefined)
1185 return result
1186 }
1187
1188 // A gated call: everything from here is audited.
1189 const state = sessionOf(await read($, session))
1190 // The prompt this call belongs to. A review runs while `next(e)` is in
1191 // flight; if the user sends a new prompt meanwhile, resetForPrompt bumps
1192 // the epoch, and a round, wipe or cached approve written afterwards must
1193 // not land in that new prompt. `thisPrompt` guards those writes.
1194 const epoch = state.promptEpoch
1195 const thisPrompt = (value: CouncilSession | undefined): boolean => sessionOf(value).promptEpoch === epoch
1196 const fingerprint = await fingerprintOf(call.tool, call.input)
1197 const operation = operationOf(call, classification, where)
1198 const decided = classification.decided
1199 const shadow = isShadow(state, settings.shadowMode)
1200 const shownCall = `${call.tool}: ${callText(call, ctx.patterns)}`hooks/audit.ts 127 lines1import { redact } from './redact.js'
2
3/**
4 * The audit log: one JSONL line per gated call. It holds no file contents,
5 * diffs or secrets: the call itself appears only as its fingerprint, and the
6 * reasons are redacted and cut.
7 */
8
9export type Decision = 'allow-once' | 'allowlist' | 'keep-blocked' | 'instruction' | 'dismissed' | 'chat' | 'unavailable'
10
11export type AuditRecord = {
12 ts: string
13 tool: string
14 /** SHA-256 of the tool and its whitespace-collapsed arguments, cut to 16 hex digits. */
15 fingerprint: string
16 /** The operation key: what the call attempts, so rephrased retries share it. */
17 opKey: string | null
18 tier: string
19 ruleId: string | null
20 member: string | null
21 profile: string | null
22 model: string | null
23 verdict: string | null
24 reason: string | null
25 shadow: boolean
26 bypass: boolean
27 /** The user's answer, when the call was escalated. */
28 decision: Decision | null
29 /**
30 * What happened to the call: it ran, ran and errored, was refused by the
31 * council, was refused by the person at Claude Code's own permission
32 * prompt, or was denied by that check with nobody asked.
33 */
34 outcome: 'ran' | 'error' | 'refused' | 'refused-by-user' | 'denied-by-permission'
35 /** An identical call was approved earlier this prompt, so no reviewer ran. */
36 cached?: true
37 /**
38 * A big operation's full council: the entry that made it big, each member's
39 * own verdict (or `failed`, `skipped`) and tokens, each check's result. No output.
40 */
41 council?: {
42 entry: string
43 voices: readonly { member: string; profile: string | null; verdict: string; tokens: number }[]
44 checks: readonly { name: string; status: string; ms: number }[]
45 }
46 /** The whole call, from the hook's start to the result: tool run and the user's answers included. */
47 latencyMs: number
48 /** The model review alone (one member, or the full council), when one ran. */
49 reviewMs?: number
50 /** The id of the allow rule the user added for this call after allowing it once. */
51 ruleAdded?: string
52 tokens: number
53 /** Present for a subagent's call. */
54 agentId?: string
55}
56
57const MAX_REASON_CHARS = 300
58
59/** The one line written for a record, newline included. */
60export function auditLine(record: AuditRecord): string {
61 const reason =
62 record.reason === null
63 ? null
64 : (() => {
65 const clean = redact(record.reason).replace(/\s+/g, ' ').trim()
66 return clean.length > MAX_REASON_CHARS ? `${clean.slice(0, MAX_REASON_CHARS - 1)}…` : clean
67 })()
68 return `${JSON.stringify({ ...record, reason })}\n`
69}
70
71// Fields whose whitespace carries no meaning, so a reformatted value is the
72// same call. A shell command is one; a file's content is NOT (indentation is
73// significant in YAML, Python, Makefiles), so it is hashed exactly, or a
74// re-indented write would reuse an earlier approve without review.
75const INSIGNIFICANT_WHITESPACE = new Set(['command'])
76
77/** Sorts keys; collapses whitespace only in fields where it has no meaning. */
78function canonical(value: unknown, collapseWhitespace = false): unknown {
79 if (typeof value === 'string') return collapseWhitespace ? value.replace(/\s+/g, ' ').trim() : value
80 if (Array.isArray(value)) return value.map(item => canonical(item, collapseWhitespace))
81 if (typeof value === 'object' && value !== null) {
82 return Object.fromEntries(
83 Object.keys(value)
84 .sort()
85 .map(key => [key, canonical((value as Record<string, unknown>)[key], INSIGNIFICANT_WHITESPACE.has(key))]),
86 )
87 }
88 return value
89}
90
91/** The text a fingerprint hashes: tool name plus arguments, canonicalised. */
92export const fingerprintText = (tool: string, input: unknown): string => `${tool}\u0000${JSON.stringify(canonical(input))}`
93
94export async function fingerprintOf(tool: string, input: unknown): Promise<string> {
95 const bytes = new TextEncoder().encode(fingerprintText(tool, input))
96 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
97 return Array.from(digest.slice(0, 8), byte => byte.toString(16).padStart(2, '0')).join('')
98}
99
100export const ROTATED_FILES = 3
101
102/**
103 * The writes that append `line` to a log holding `current`: the new current
104 * file, plus the rotated files when the line would take it past `maxBytes`.
105 * `older` holds `<path>.1` .. `<path>.N-1` as read (undefined when absent).
106 */
107export function appendPlan(
108 path: string,
109 current: string,
110 line: string,
111 maxBytes: number,
112 older: readonly (string | undefined)[],
113): { path: string; text: string }[] {
114 const size = new TextEncoder().encode(current).length + new TextEncoder().encode(line).length
115 if (current === '' || size <= maxBytes) return [{ path, text: current + line }]
116 const writes: { path: string; text: string }[] = []
117 for (let n = ROTATED_FILES - 1; n >= 1; n--) {
118 const source = n === 1 ? current : older[n - 2]
119 if (source !== undefined) writes.push({ path: `${path}.${n}`, text: source })
120 }
121 writes.push({ path, text: line })
122 return writes
123}
124
125/** The `.gitignore` written beside the log, so it is never committed. */
126export const AUDIT_GITIGNORE = '# Written by the council mod: the audit log stays local.\n*\n'
127hooks/config/defaults.ts 321 lines1import type { Config, Rule } from './types.js'
2
3/**
4 * The shipped rules. Shell `command` patterns match one part of a compound
5 * command at a time, after quotes are removed and wrappers (`sudo`, `env`,
6 * `nice`, `timeout`, ...) and leading `NAME=value` assignments are stripped,
7 * with git's global options (`-C dir`, `-c k=v`) dropped. They are anchored
8 * at the program name.
9 *
10 * The rules are the safety boundary; the model members are a second opinion.
11 */
12
13const SHELL = ['Bash', 'Monitor'] as const
14const FILES = ['Write', 'Edit', 'NotebookEdit'] as const
15
16const BLOCK: readonly Rule[] = [
17 {
18 id: 'rm-recursive-outside-repo',
19 tier: 'block',
20 tools: SHELL,
21 check: 'rm-outside-repo',
22 reason:
23 'Recursive delete of the filesystem root, the home directory or a path outside the project.',
24 },
25 {
26 id: 'force-push-protected-branch',
27 tier: 'block',
28 tools: SHELL,
29 check: 'force-push-protected',
30 reason: 'Force push (or delete) of a protected branch.',
31 },
32 {
33 id: 'destructive-sql-production',
34 tier: 'block',
35 tools: SHELL,
36 check: 'destructive-sql-production',
37 reason: 'DROP or TRUNCATE against a target that looks like production.',
38 },
39 {
40 id: 'raw-disk-write',
41 tier: 'block',
42 tools: SHELL,
43 check: 'raw-disk-write',
44 reason: 'Writes directly to a disk device.',
45 },
46]
47
48const ASK: readonly Rule[] = [
49 {
50 id: 'privileged',
51 tier: 'ask',
52 tools: SHELL,
53 check: 'privileged',
54 reason: 'Runs with elevated privileges (sudo, doas, su).',
55 },
56]
57
58const REVIEW: readonly Rule[] = [
59 {
60 id: 'sql-file-write',
61 tier: 'review',
62 tools: FILES,
63 path: String.raw`\.sql$`,
64 member: 'aragorn',
65 profile: 'database',
66 reason: 'Writes or edits SQL.',
67 },
68 {
69 id: 'file-write',
70 tier: 'review',
71 tools: FILES,
72 member: 'legolas',
73 reason: 'Writes or edits a file.',
74 },
75 {
76 id: 'shell-delete',
77 tier: 'review',
78 tools: SHELL,
79 command: String.raw`^(rm|rmdir|unlink|shred|srm|trash|trash-put|rimraf|del-cli|trash-cli)(\s|$)`,
80 reason: 'Deletes files.',
81 },
82 {
83 id: 'shell-find-delete',
84 tier: 'review',
85 tools: SHELL,
86 command: String.raw`^find\s.*\s(-delete|-exec(dir)?\s|-ok(dir)?\s)`,
87 reason: 'Finds files and deletes or runs a command on each.',
88 },
89 {
90 id: 'shell-move',
91 tier: 'review',
92 tools: SHELL,
93 command: String.raw`^mv(\s|$)`,
94 reason: 'Moves or renames files, which can overwrite.',
95 },
96 {
97 id: 'shell-overwrite-tools',
98 tier: 'review',
99 tools: SHELL,
100 command: String.raw`^(dd|mkfs(\.\w+)?|truncate|wipefs|fdisk|parted|sfdisk)(\s|$)`,
101 reason: 'Overwrites or truncates data in place.',
102 },
103 {
104 id: 'shell-recursive-permissions',
105 tier: 'review',
106 tools: SHELL,
107 command: String.raw`^(chmod|chown|chgrp)\s(.*\s)?(-[a-zA-Z]*R[a-zA-Z]*|--recursive)(\s|$)`,
108 reason: 'Changes permissions or ownership recursively.',
109 },
110 {
111 id: 'shell-in-place-edit',
112 tier: 'review',
113 tools: SHELL,
114 command: String.raw`^(sed|perl|ruby)\s(.*\s)?(-[a-zA-Z]*i[a-zA-Z]*|--in-place)(\S*)(\s|$)`,
115 reason: 'Edits files in place.',
116 },
117 {
118 id: 'shell-redirect-write',
119 tier: 'review',
120 tools: SHELL,
121 check: 'redirect-write',
122 reason: 'Writes or overwrites a file through a shell redirect.',
123 },
124 {
125 id: 'shell-tee',
126 tier: 'review',
127 tools: SHELL,
128 command: String.raw`^tee(\s|$)`,
129 reason: 'Writes a file with tee.',
130 },
131 {
132 id: 'git-remote-or-history',
133 tier: 'review',
134 tools: SHELL,
135 command: String.raw`^git\s(push|merge|rebase|reset|tag|filter-branch|filter-repo|cherry-pick|revert|update-ref|replace)(\s|$)|^git\scommit\s(.*\s)?--amend(\s|$)`,
136 member: 'aragorn',
137 profile: 'git',
138 reason: 'Changes a remote or rewrites git history.',
139 },
140 {
141 id: 'git-config-write',
142 tier: 'review',
143 tools: SHELL,
144 check: 'git-config-write',
145 member: 'aragorn',
146 profile: 'git',
147 reason: 'Writes git configuration (.git/config), which can set hooks, a pager or an SSH command that runs code.',
148 },
149 {
150 id: 'git-config-injection',
151 tier: 'review',
152 tools: SHELL,
153 check: 'git-config-injection',
154 member: 'aragorn',
155 profile: 'git',
156 reason: 'Passes git a configuration key, exec-path or bisect command that can run arbitrary code.',
157 },
158 {
159 id: 'dangerous-env-assignment',
160 tier: 'review',
161 tools: SHELL,
162 check: 'dangerous-env-assignment',
163 reason: 'Sets an environment variable that changes what a later program loads or runs.',
164 },
165 {
166 id: 'git-discard',
167 tier: 'review',
168 tools: SHELL,
169 command: String.raw`^git\s(clean|restore|worktree\sremove)(\s|$)|^git\sstash\s(drop|clear)(\s|$)|^git\sbranch\s(.*\s)?-[a-zA-Z]*D[a-zA-Z]*(\s|$)|^git\sbranch\s(.*\s)?(--delete|-d)\s(.*\s)?(--force|-f)(\s|$)|^git\scheckout\s(.*\s)?(--|-f|--force|\.)(\s|$)|^git\sgc\s(.*\s)?--prune|^git\sreflog\s(expire|delete)(\s|$)`,
170 reason: 'Discards git work that may not be recoverable.',
171 },
172 {
173 id: 'database-client',
174 tier: 'review',
175 tools: SHELL,
176 command: String.raw`^(psql|pg_restore|dropdb|createdb|mysql|mariadb|mysqladmin|sqlite3|mongo|mongosh|mongorestore|redis-cli|cqlsh|sqlcmd|clickhouse(-client)?)(\s|$)`,
177 member: 'aragorn',
178 profile: 'database',
179 reason: 'Runs a database client.',
180 },
181 {
182 id: 'database-migration',
183 tier: 'review',
184 tools: SHELL,
185 command: String.raw`^((npx|bunx|pnpm(\sexec|\sdlx)?|yarn)\s)?(prisma\s(migrate|db\spush|db\sexecute)|knex\smigrate|sequelize(-cli)?\sdb:|typeorm\smigration|drizzle-kit\s(push|migrate|drop)|alembic\s(upgrade|downgrade|stamp)|flyway|liquibase|goose|dbmate|atlas\s(migrate|schema\sapply)|migrate\s)|^(python\d*(\.\d+)?\s)?(\S*/)?manage\.py\s(migrate|flush|sqlflush|reset_db)|^((bundle\sexec|bin/)\s?)?(rails|rake)\sdb:`,
186 member: 'aragorn',
187 profile: 'database',
188 reason: 'Runs a database migration.',
189 },
190 {
191 id: 'infrastructure',
192 tier: 'review',
193 tools: SHELL,
194 command: String.raw`^(terraform|tofu)\s(apply|destroy|import|taint|state\s(rm|mv|push))|^kubectl\s(delete|apply|replace|patch|scale|drain|cordon|rollout\sundo|set)|^helm\s(install|upgrade|uninstall|delete|rollback)|^docker\s((container|image|volume|network|system|builder)\s)?(rm|rmi|prune)|^docker[\s-]compose\s(.*\s)?down|^(aws|gcloud|az)\s.*\s(delete|remove|rm|destroy|terminate)`,
195 reason: 'Changes or deletes infrastructure.',
196 },
197 {
198 id: 'network-write',
199 tier: 'review',
200 tools: SHELL,
201 command: String.raw`^curl\s(.*\s)?(-X\s?(POST|PUT|PATCH|DELETE)|--request\s(POST|PUT|PATCH|DELETE)|-d|--data(-\w+)?|-F|--form|-T|--upload-file)(\s|$|=)|^wget\s(.*\s)?--(post|method|body)`,
202 reason: 'Sends data to a remote service.',
203 },
204 {
205 id: 'remote-copy',
206 tier: 'review',
207 tools: SHELL,
208 command: String.raw`^(scp|sftp)(\s|$)|^rsync\s(.*\s)?--(delete\S*|remove-source-files)(\s|$)`,
209 reason: 'Copies to another machine, or syncs with deletion.',
210 },
211 {
212 id: 'publish',
213 tier: 'review',
214 tools: SHELL,
215 command: String.raw`^(npm|pnpm|yarn)\s(.*\s)?publish(\s|$)|^cargo\spublish|^twine\supload|^gem\spush|^gh\s(release\s(create|delete|upload)|repo\s(delete|archive|edit)|pr\smerge|api\s.*-X\s?(POST|PUT|PATCH|DELETE))`,
216 reason: 'Publishes or changes something outside this machine.',
217 },
218 {
219 id: 'script-shell',
220 tier: 'review',
221 tools: SHELL,
222 command: String.raw`^(bash|sh|zsh|dash|ksh|fish|csh|tcsh)(\s|$)`,
223 reason: 'Runs a shell script, inline shell code or code piped into a shell.',
224 },
225 {
226 id: 'script-inline',
227 tier: 'review',
228 tools: SHELL,
229 command: String.raw`^(python\d*(\.\d+)?|node|nodejs|deno|bun|ruby|perl|php|lua|Rscript|osascript|pwsh|powershell)\s(.*\s)?(-c|-e|--eval|-p|--print|-r|-|-Command)(\s|$)`,
230 reason: 'Runs inline code.',
231 },
232 {
233 id: 'script-file',
234 tier: 'review',
235 tools: SHELL,
236 command: String.raw`^(python\d*(\.\d+)?|node|nodejs|deno(\srun)?|bun(\srun)?|ruby|perl|php|lua|Rscript|ts-node|tsx)\s(-\S+\s)*[^-\s]\S*\.(py|js|mjs|cjs|ts|mts|cts|rb|pl|php|lua|R)(\s|$)`,
237 reason: 'Runs a script file.',
238 },
239 {
240 id: 'script-path',
241 tier: 'review',
242 tools: SHELL,
243 command: String.raw`^[^\s/]*/\S*(\s|$)`,
244 reason: 'Runs a program or script by its path.',
245 },
246 {
247 id: 'script-eval',
248 tier: 'review',
249 tools: SHELL,
250 command: String.raw`^(eval|source|\.|xargs|parallel)(\s|$)|^psql\s(.*\s)?(-f|--file)(\s|=|$)`,
251 reason: 'Evaluates code built at run time or from a file (eval, source, xargs, parallel).',
252 },
253 {
254 id: 'remote-exec',
255 tier: 'review',
256 tools: SHELL,
257 command: String.raw`^(ssh|nc|ncat|netcat|telnet)(\s|$)|^rsync\s(.*\s)?(-e|--rsync-path)(\s|=)|^docker\s(.*\s)?(exec|run)(\s|$)|^docker[\s-]compose\s(.*\s)?(exec|run)(\s|$)|^(podman|nerdctl)\s(.*\s)?(exec|run)(\s|$)|^kubectl\s(.*\s)?exec(\s|$)|^(heroku|fly|flyctl)\s(.*\s)?(run|ssh)(\s|$)|^gcloud\s(.*\s)?ssh(\s|$)|^aws\s(.*\s)?(ssm\s(start-session|send-command))`,
258 reason: 'Runs a command on another machine or inside a container.',
259 },
260 {
261 id: 'sandbox-disabled',
262 tier: 'review',
263 tools: ['Bash'],
264 input: String.raw`"dangerouslyDisableSandbox":true`,
265 reason: 'Runs a command with the sandbox disabled.',
266 },
267 {
268 id: 'mcp-mutating',
269 tier: 'review',
270 tools: [String.raw`/^mcp__.+__.*(delete|remove|drop|merge|push|write|update|create|send|execute|exec|run|deploy|publish|destroy|purge|truncate|move|rename|upload|post|insert|modify|set)/i`],
271 reason: 'Calls an MCP tool whose name says it changes something.',
272 },
273]
274
275export const SHIPPED: Config = {
276 schemaVersion: 1,
277 rules: [...BLOCK, ...ASK, ...REVIEW],
278 disableRules: [],
279 protectedPaths: [
280 '**/.env',
281 '**/.env.*',
282 '.github/workflows/**',
283 '.gitlab-ci.yml',
284 '.circleci/**',
285 '.buildkite/**',
286 '**/Jenkinsfile',
287 '**/migrations/**',
288 'db/migrate/**',
289 '.git/**',
290 '.claude/council-of-elrond/**',
291 '.claude/settings.json',
292 '.claude/settings.local.json',
293 ],
294 protectedBranches: ['main', 'master', 'production', 'prod', 'release/*', 'trunk'],
295 productionPatterns: [
296 String.raw`(^|[^a-z0-9])prod(uction)?([^a-z0-9]|$)`,
297 String.raw`(^|[^a-z0-9])live([^a-z0-9]|$)`,
298 String.raw`(^|[^a-z0-9])primary([^a-z0-9]|$)`,
299 ],
300 models: {},
301 gollum: { patterns: [], allowlist: [] },
302 // A push, a merge into a protected branch, a migration: the full council.
303 bigOperations: [String.raw`/^git\s+push(\s|$)/`, 'merge-to-protected', 'database-migration'],
304 gimli: { commands: [] },
305}
306
307/**
308 * The built-in model per slot: the latest Sonnet as the floor, the latest
309 * Opus where the stakes are higher. Aliases resolve to the newest model of
310 * that family the running Claude Code knows.
311 */
312export const BUILT_IN_MODELS = {
313 gandalf: 'sonnet',
314 legolas: 'sonnet',
315 aragorn: 'opus',
316 council: 'opus',
317} as const
318
319/** The project overrides file, relative to the project root. */
320export const OVERRIDES_PATH = '.claude/council-of-elrond/rules.json'
321hooks/config/schema.ts 525 lines1import { globToRegExp, protectedMatcher, slashRegex, toolMatcher } from '../rules/globs.js'
2import type { ProtectedMatcher } from '../rules/globs.js'
3import { SHIPPED } from './defaults.js'
4import {
5 BIG_CHECKS,
6 CHECKS,
7 MEMBERS,
8 MODEL_SLOTS,
9 PROFILES,
10 SECRET_LEVELS,
11 TIERS,
12} from './types.js'
13import type {
14 BigCheck,
15 CheckName,
16 Config,
17 GimliCommand,
18 GollumPattern,
19 MemberName,
20 ModelSlot,
21 Overrides,
22 Profile,
23 Rule,
24 RuleSource,
25 Tier,
26} from './types.js'
27
28export const SCHEMA_VERSION = 1
29
30export type CompiledRule = Rule & {
31 source: RuleSource
32 matchesTool: (tool: string) => boolean
33 commandRe?: RegExp
34 pathRe?: RegExp
35 inputRe?: RegExp
36}
37
38/** A big-operation entry, compiled: which rule, which command pattern, or which check. */
39export type BigOperation =
40 | { kind: 'rule'; entry: string; ruleId: string }
41 | { kind: 'command'; entry: string; re: RegExp }
42 | { kind: 'check'; entry: string; check: BigCheck }
43
44export type CompiledConfig = {
45 config: Config
46 /** Project rules first, in file order, then the enabled shipped rules. */
47 rules: readonly CompiledRule[]
48 protectedPaths: readonly ProtectedMatcher[]
49 protectedBranches: readonly RegExp[]
50 production: readonly RegExp[]
51 bigOperations: readonly BigOperation[]
52}
53
54export type LoadedConfig = {
55 compiled: CompiledConfig
56 /** Where the effective config came from. */
57 origin: 'shipped' | 'shipped+project'
58 /** Problems with the overrides file, by field; non-empty means it was ignored. */
59 errors: readonly string[]
60}
61
62const TOP_KEYS = [
63 'schemaVersion',
64 'rules',
65 'disableRules',
66 'protectedPaths',
67 'protectedBranches',
68 'productionPatterns',
69 'models',
70 'gollum',
71 'bigOperations',
72 'gimli',
73] as const
74
75const GIMLI_KEYS = ['commands'] as const
76const GIMLI_COMMAND_KEYS = ['name', 'argv', 'timeoutMs'] as const
77
78/** A check command's timeout: 120 s unless set, between 1 s and 10 minutes (the most a process may run). */
79export const GIMLI_TIMEOUT = { default: 120_000, min: 1_000, max: 600_000 } as const
80
81const MAX_GIMLI_COMMANDS = 8
82
83const GOLLUM_KEYS = ['patterns', 'allowlist'] as const
84const GOLLUM_PATTERN_KEYS = ['id', 'level', 'regex', 'label'] as const
85
86/** A fingerprint as the dialog shows it; anything else in the allowlist is an exact string. */
87export const SECRET_FINGERPRINT = /^sha256:[0-9a-f]{16}$/
88
89const MAX_ALLOWLIST_ENTRY = 500
90
91const RULE_KEYS = [
92 'id',
93 'tier',
94 'tools',
95 'command',
96 'path',
97 'input',
98 'check',
99 'member',
100 'profile',
101 'reason',
102] as const
103
104export const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._\-[\]@:/]{0,99}$/
105
106const isObject = (value: unknown): value is Record<string, unknown> =>
107 typeof value === 'object' && value !== null && !Array.isArray(value)
108
109const includes = <T extends string>(list: readonly T[], value: unknown): value is T =>
110 typeof value === 'string' && (list as readonly string[]).includes(value)
111
112function regexError(source: unknown, flags = ''): string | undefined {
113 if (typeof source !== 'string' || source === '') return 'must be a non-empty regex string'
114 try {
115 new RegExp(source, flags)
116 return undefined
117 } catch (error) {
118 return `invalid regex: ${error instanceof Error ? error.message : String(error)}`
119 }
120}
121
122function stringList(value: unknown, field: string, errors: string[]): string[] {
123 if (!Array.isArray(value)) {
124 errors.push(`${field}: must be a list of strings`)
125 return []
126 }
127 value.forEach((item, index) => {
128 if (typeof item !== 'string' || item === '') {
129 errors.push(`${field}[${index}]: must be a non-empty string`)
130 }
131 })
132 return value.filter((item): item is string => typeof item === 'string' && item !== '')
133}
134
135function validateRule(raw: unknown, field: string, errors: string[]): Rule | undefined {
136 if (!isObject(raw)) {
137 errors.push(`${field}: must be an object`)
138 return undefined
139 }
140 const before = errors.length
141 for (const key of Object.keys(raw)) {
142 if (!includes(RULE_KEYS, key)) errors.push(`${field}.${key}: unknown field`)
143 }
144 if (typeof raw.id !== 'string' || raw.id === '') errors.push(`${field}.id: required string`)
145 if (!includes(TIERS, raw.tier)) errors.push(`${field}.tier: one of ${TIERS.join(', ')}`)
146 const tools = stringList(raw.tools, `${field}.tools`, errors)
147 if (Array.isArray(raw.tools) && raw.tools.length === 0) {
148 errors.push(`${field}.tools: name at least one tool`)
149 }
150 tools.forEach((pattern, index) => {
151 try {
152 toolMatcher(pattern)
153 } catch (error) {
154 errors.push(`${field}.tools[${index}]: ${error instanceof Error ? error.message : String(error)}`)
155 }
156 })
157 for (const key of ['command', 'path', 'input'] as const) {
158 if (raw[key] !== undefined) {
159 const problem = regexError(raw[key])
160 if (problem !== undefined) errors.push(`${field}.${key}: ${problem}`)
161 }
162 }
163 if (raw.check !== undefined && !includes(CHECKS, raw.check)) {
164 errors.push(`${field}.check: one of ${CHECKS.join(', ')}`)
165 }
166 if (raw.member !== undefined && !includes(MEMBERS, raw.member)) {
167 errors.push(`${field}.member: one of ${MEMBERS.join(', ')}`)
168 }
169 if (raw.profile !== undefined) {
170 if (!includes(PROFILES, raw.profile)) {
171 errors.push(`${field}.profile: one of ${PROFILES.join(', ')}`)
172 } else if (raw.member !== 'aragorn') {
173 errors.push(`${field}.profile: only Aragorn (member "aragorn") has profiles`)
174 }
175 }
176 if (typeof raw.reason !== 'string' || raw.reason.trim() === '') {
177 errors.push(`${field}.reason: required string`)
178 }
179 if (errors.length > before) return undefined
180 return {
181 id: raw.id as string,
182 tier: raw.tier as Tier,
183 tools,
184 ...(raw.command !== undefined && { command: raw.command as string }),
185 ...(raw.path !== undefined && { path: raw.path as string }),
186 ...(raw.input !== undefined && { input: raw.input as string }),
187 ...(raw.check !== undefined && { check: raw.check as CheckName }),
188 ...(raw.member !== undefined && { member: raw.member as MemberName }),
189 ...(raw.profile !== undefined && { profile: raw.profile as Profile }),
190 reason: (raw.reason as string).trim(),
191 }
192}
193
194function validateGollum(raw: unknown, errors: string[]): Overrides['gollum'] {
195 if (!isObject(raw)) {
196 errors.push('gollum: must be an object')
197 return undefined
198 }
199 for (const key of Object.keys(raw)) {
200 if (!includes(GOLLUM_KEYS, key)) errors.push(`gollum.${key}: unknown field`)
201 }
202 const out: { patterns?: GollumPattern[]; allowlist?: string[] } = {}
203 if (raw.patterns !== undefined) {
204 if (!Array.isArray(raw.patterns)) {
205 errors.push('gollum.patterns: must be a list')
206 } else {
207 const seen = new Set<string>()
208 out.patterns = []
209 raw.patterns.forEach((item, index) => {
210 const field = `gollum.patterns[${index}]`
211 if (!isObject(item)) {
212 errors.push(`${field}: must be an object`)
213 return
214 }
215 const before = errors.length
216 for (const key of Object.keys(item)) {
217 if (!includes(GOLLUM_PATTERN_KEYS, key)) errors.push(`${field}.${key}: unknown field`)
218 }
219 if (typeof item.id !== 'string' || item.id === '') errors.push(`${field}.id: required string`)
220 else if (seen.has(item.id)) errors.push(`${field}.id: "${item.id}" is used twice`)
221 if (!includes(SECRET_LEVELS, item.level)) errors.push(`${field}.level: one of ${SECRET_LEVELS.join(', ')}`)
222 const problem = regexError(item.regex, 'g')
223 if (problem !== undefined) errors.push(`${field}.regex: ${problem}`)
224 else if (new RegExp(item.regex as string).test('')) errors.push(`${field}.regex: must not match empty text`)
225 if (typeof item.label !== 'string' || item.label.trim() === '') errors.push(`${field}.label: required string`)
226 if (errors.length > before) return
227 seen.add(item.id as string)
228 out.patterns?.push({
229 id: item.id as string,
230 level: item.level as GollumPattern['level'],
231 regex: item.regex as string,
232 label: (item.label as string).trim(),
233 })
234 })
235 }
236 }
237 if (raw.allowlist !== undefined) {
238 const entries = stringList(raw.allowlist, 'gollum.allowlist', errors)
239 entries.forEach((entry, index) => {
240 if (entry.length > MAX_ALLOWLIST_ENTRY) errors.push(`gollum.allowlist[${index}]: longer than ${MAX_ALLOWLIST_ENTRY} characters`)
241 else if (!SECRET_FINGERPRINT.test(entry) && entry.length < 6) {
242 errors.push(`gollum.allowlist[${index}]: an exact secret of at least 6 characters, or a sha256: fingerprint`)
243 }
244 })
245 out.allowlist = entries
246 }
247 return out
248}
249
250function validateGimli(raw: unknown, errors: string[]): Overrides['gimli'] {
251 if (!isObject(raw)) {
252 errors.push('gimli: must be an object')
253 return undefined
254 }
255 for (const key of Object.keys(raw)) {
256 if (!includes(GIMLI_KEYS, key)) errors.push(`gimli.${key}: unknown field`)
257 }
258 if (raw.commands === undefined) return {}
259 if (!Array.isArray(raw.commands)) {
260 errors.push('gimli.commands: must be a list')
261 return undefined
262 }
263 if (raw.commands.length > MAX_GIMLI_COMMANDS) errors.push(`gimli.commands: at most ${MAX_GIMLI_COMMANDS} commands`)
264 const seen = new Set<string>()
265 const commands: GimliCommand[] = []
266 raw.commands.forEach((item, index) => {
267 const field = `gimli.commands[${index}]`
268 if (!isObject(item)) {
269 errors.push(`${field}: must be an object`)
270 return
271 }
272 const before = errors.length
273 for (const key of Object.keys(item)) {
274 if (!includes(GIMLI_COMMAND_KEYS, key)) errors.push(`${field}.${key}: unknown field`)
275 }
276 if (typeof item.name !== 'string' || item.name.trim() === '') errors.push(`${field}.name: required string`)
277 else if (seen.has(item.name.trim())) errors.push(`${field}.name: "${item.name.trim()}" is used twice`)
278 if (!Array.isArray(item.argv) || item.argv.length === 0) {
279 errors.push(`${field}.argv: a non-empty list of strings (the command and its arguments; no shell)`)
280 } else {
281 item.argv.forEach((word, i) => {
282 if (typeof word !== 'string' || (i === 0 && word.trim() === '')) errors.push(`${field}.argv[${i}]: must be a${i === 0 ? ' non-empty' : ''} string`)
283 })
284 }
285 const timeout = item.timeoutMs
286 if (timeout !== undefined && (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout < GIMLI_TIMEOUT.min || timeout > GIMLI_TIMEOUT.max)) {
287 errors.push(`${field}.timeoutMs: a whole number of milliseconds from ${GIMLI_TIMEOUT.min} to ${GIMLI_TIMEOUT.max}`)
288 }
289 if (errors.length > before) return
290 const name = (item.name as string).trim()
291 seen.add(name)
292 commands.push({ name, argv: [...(item.argv as string[])], timeoutMs: (timeout as number | undefined) ?? GIMLI_TIMEOUT.default })
293 })
294 return { commands }
295}
296
297/**
298 * A big-operation entry: `/regex/flags`, a named check, or a rule id the
299 * merged config holds (shipped, or the file's own).
300 */
301function validateBigOperations(raw: unknown, ruleIds: ReadonlySet<string>, errors: string[]): string[] {
302 const entries = stringList(raw, 'bigOperations', errors)
303 entries.forEach((entry, index) => {
304 const field = `bigOperations[${index}]`
305 if (entry.startsWith('/')) {
306 try {
307 if (slashRegex(entry) === undefined) errors.push(`${field}: a regex is written /source/flags`)
308 } catch (error) {
309 errors.push(`${field}: invalid regex: ${error instanceof Error ? error.message : String(error)}`)
310 }
311 } else if (!includes(BIG_CHECKS, entry) && !ruleIds.has(entry)) {
312 errors.push(`${field}: "${entry}" is not a rule id, a /regex/ or one of ${BIG_CHECKS.join(', ')}`)
313 }
314 })
315 return entries
316}
317
318/**
319 * Validates the parsed overrides file against this build's schema, reporting
320 * every problem by field. Any problem means the whole file is ignored.
321 */
322export function validateOverrides(
323 raw: unknown,
324 shipped: Config = SHIPPED,
325): { overrides: Overrides; errors: [] } | { overrides: undefined; errors: string[] } {
326 const errors: string[] = []
327 if (!isObject(raw)) {
328 return { overrides: undefined, errors: ['(file): must be a JSON object'] }
329 }
330 if (raw.schemaVersion !== SCHEMA_VERSION) {
331 return {
332 overrides: undefined,
333 errors: [
334 `schemaVersion: ${JSON.stringify(raw.schemaVersion)} is not a version this build reads (${SCHEMA_VERSION})`,
335 ],
336 }
337 }
338 for (const key of Object.keys(raw)) {
339 if (!includes(TOP_KEYS, key)) errors.push(`${key}: unknown field`)
340 }
341
342 const shippedIds = new Map(shipped.rules.map(rule => [rule.id, rule]))
343 const overrides: Overrides = { schemaVersion: 1 }
344
345 if (raw.rules !== undefined) {
346 if (!Array.isArray(raw.rules)) {
347 errors.push('rules: must be a list')
348 } else {
349 const seen = new Set<string>()
350 const rules: Rule[] = []
351 raw.rules.forEach((item, index) => {
352 const rule = validateRule(item, `rules[${index}]`, errors)
353 if (rule === undefined) return
354 if (seen.has(rule.id)) errors.push(`rules[${index}].id: "${rule.id}" is used twice`)
355 if (includes(BIG_CHECKS, rule.id)) errors.push(`rules[${index}].id: "${rule.id}" is the name of a big-operation check`)
356 if (shippedIds.has(rule.id)) {
357 errors.push(
358 `rules[${index}].id: "${rule.id}" is a shipped rule's id; disable it with disableRules and give yours another id`,
359 )
360 }
361 seen.add(rule.id)
362 rules.push(rule)
363 })
364 overrides.rules = rules
365 }
366 }
367
368 if (raw.disableRules !== undefined) {
369 const ids = stringList(raw.disableRules, 'disableRules', errors)
370 ids.forEach((id, index) => {
371 const rule = shippedIds.get(id)
372 if (rule === undefined) {
373 errors.push(`disableRules[${index}]: "${id}" is not a shipped rule`)
374 } else if (rule.tier === 'block') {
375 errors.push(`disableRules[${index}]: "${id}" is a block rule; block rules cannot be disabled`)
376 }
377 })
378 overrides.disableRules = ids
379 }
380
381 if (raw.protectedPaths !== undefined) {
382 overrides.protectedPaths = stringList(raw.protectedPaths, 'protectedPaths', errors)
383 }
384 if (raw.protectedBranches !== undefined) {
385 overrides.protectedBranches = stringList(raw.protectedBranches, 'protectedBranches', errors)
386 }
387 if (raw.productionPatterns !== undefined) {
388 const patterns = stringList(raw.productionPatterns, 'productionPatterns', errors)
389 patterns.forEach((pattern, index) => {
390 const problem = regexError(pattern, 'i')
391 if (problem !== undefined) errors.push(`productionPatterns[${index}]: ${problem}`)
392 })
393 overrides.productionPatterns = patterns
394 }
395
396 if (raw.models !== undefined) {
397 if (!isObject(raw.models)) {
398 errors.push('models: must be an object')
399 } else {
400 const models: Partial<Record<ModelSlot, string>> = {}
401 for (const [slot, model] of Object.entries(raw.models)) {
402 if (!includes(MODEL_SLOTS, slot)) {
403 errors.push(`models.${slot}: unknown slot (one of ${MODEL_SLOTS.join(', ')})`)
404 } else if (typeof model !== 'string' || !MODEL_ID.test(model)) {
405 errors.push(`models.${slot}: must be a model alias or id`)
406 } else {
407 models[slot] = model
408 }
409 }
410 overrides.models = models
411 }
412 }
413
414 if (raw.gollum !== undefined) {
415 const gollum = validateGollum(raw.gollum, errors)
416 if (gollum !== undefined) overrides.gollum = gollum
417 }
418
419 if (raw.bigOperations !== undefined) {
420 const ruleIds = new Set([...shippedIds.keys(), ...(overrides.rules ?? []).map(rule => rule.id)])
421 overrides.bigOperations = validateBigOperations(raw.bigOperations, ruleIds, errors)
422 }
423
424 if (raw.gimli !== undefined) {
425 const gimli = validateGimli(raw.gimli, errors)
426 if (gimli !== undefined) overrides.gimli = gimli
427 }
428
429 return errors.length > 0
430 ? { overrides: undefined, errors }
431 : { overrides, errors: [] }
432}
433
434const union = (a: readonly string[], b: readonly string[] = []): string[] => [
435 ...new Set([...a, ...b]),
436]
437
438/**
439 * Overrides win: project rules are tried before shipped ones, and project
440 * models stand. Lists only add to the shipped ones, so a project can widen
441 * the protection but never narrow it; block rules cannot be disabled.
442 */
443export function mergeConfig(shipped: Config, overrides: Overrides): Config {
444 const disabled = new Set(overrides.disableRules ?? [])
445 return {
446 schemaVersion: 1,
447 rules: [
448 ...(overrides.rules ?? []),
449 ...shipped.rules.filter(rule => rule.tier === 'block' || !disabled.has(rule.id)),
450 ],
451 disableRules: [...disabled],
452 protectedPaths: union(shipped.protectedPaths, overrides.protectedPaths),
453 protectedBranches: union(shipped.protectedBranches, overrides.protectedBranches),
454 productionPatterns: union(shipped.productionPatterns, overrides.productionPatterns),
455 models: { ...shipped.models, ...overrides.models },
456 gollum: {
457 patterns: [...shipped.gollum.patterns, ...(overrides.gollum?.patterns ?? [])],
458 allowlist: union(shipped.gollum.allowlist, overrides.gollum?.allowlist),
459 },
460 bigOperations: union(shipped.bigOperations, overrides.bigOperations),
461 gimli: { commands: [...shipped.gimli.commands, ...(overrides.gimli?.commands ?? [])] },
462 }
463}
464
465function compileBigOperation(entry: string): BigOperation {
466 const re = slashRegex(entry)
467 if (re !== undefined) return { kind: 'command', entry, re }
468 return includes(BIG_CHECKS, entry) ? { kind: 'check', entry, check: entry } : { kind: 'rule', entry, ruleId: entry }
469}
470
471const branchToRegExp = (branch: string): RegExp => globToRegExp(branch)
472
473export function compileConfig(config: Config, projectRuleIds: ReadonlySet<string>): CompiledConfig {
474 const rules = config.rules.map((rule): CompiledRule => {
475 const matchers = rule.tools.map(toolMatcher)
476 return {
477 ...rule,
478 source: projectRuleIds.has(rule.id) ? 'project' : 'shipped',
479 matchesTool: tool => matchers.some(matches => matches(tool)),
480 ...(rule.command !== undefined && { commandRe: new RegExp(rule.command) }),
481 ...(rule.path !== undefined && { pathRe: new RegExp(rule.path) }),
482 ...(rule.input !== undefined && { inputRe: new RegExp(rule.input) }),
483 }
484 })
485 return {
486 config,
487 rules,
488 protectedPaths: config.protectedPaths.map(protectedMatcher),
489 protectedBranches: config.protectedBranches.map(branchToRegExp),
490 production: config.productionPatterns.map(pattern => new RegExp(pattern, 'i')),
491 bigOperations: config.bigOperations.map(compileBigOperation),
492 }
493}
494
495/**
496 * The effective config from the overrides file's text (undefined: no file).
497 * A file that does not parse or validate is ignored whole, and the shipped
498 * defaults stand, with the errors reported. It never turns the gate off.
499 */
500export function loadConfig(fileText: string | undefined, shipped: Config = SHIPPED): LoadedConfig {
501 const shippedOnly = (errors: readonly string[]): LoadedConfig => ({
502 compiled: compileConfig(shipped, new Set()),
503 origin: 'shipped',
504 errors,
505 })
506 if (fileText === undefined) return shippedOnly([])
507
508 let raw: unknown
509 try {
510 raw = JSON.parse(fileText)
511 } catch (error) {
512 return shippedOnly([`(file): not valid JSON: ${error instanceof Error ? error.message : String(error)}`])
513 }
514 const result = validateOverrides(raw, shipped)
515 if (result.overrides === undefined) return shippedOnly(result.errors)
516
517 const merged = mergeConfig(shipped, result.overrides)
518 const projectIds = new Set((result.overrides.rules ?? []).map(rule => rule.id))
519 try {
520 return { compiled: compileConfig(merged, projectIds), origin: 'shipped+project', errors: [] }
521 } catch (error) {
522 return shippedOnly([`(file): ${error instanceof Error ? error.message : String(error)}`])
523 }
524}
525hooks/config/types.ts 133 lines1/**
2 * The council's configuration: the shipped defaults (defaults.ts) merged with
3 * the project overrides file (.claude/council-of-elrond/rules.json).
4 */
5
6/** Tiers, least strict first: the strictest across a call's parts wins. */
7export const TIERS = ['allow', 'review', 'ask', 'block'] as const
8export type Tier = (typeof TIERS)[number]
9
10export const MEMBERS = ['gandalf', 'legolas', 'aragorn'] as const
11export type MemberName = (typeof MEMBERS)[number]
12
13export const PROFILES = ['git', 'database'] as const
14export type Profile = (typeof PROFILES)[number]
15
16/** Model slots: one per model member, plus the full council's. */
17export const MODEL_SLOTS = ['gandalf', 'legolas', 'aragorn', 'council'] as const
18export type ModelSlot = (typeof MODEL_SLOTS)[number]
19
20/**
21 * Checks written in code, for what a pattern cannot say safely: they read the
22 * parsed words of a shell part rather than its text.
23 */
24export const CHECKS = [
25 'rm-outside-repo',
26 'force-push-protected',
27 'destructive-sql-production',
28 'privileged',
29 'redirect-write',
30 'git-config-write',
31 'git-config-injection',
32 'dangerous-env-assignment',
33 'raw-disk-write',
34] as const
35export type CheckName = (typeof CHECKS)[number]
36
37export type Rule = {
38 /** Unique id, shown in /council rules and the audit log. */
39 id: string
40 tier: Tier
41 /**
42 * The tools the rule covers: an exact name (`Bash`), a glob (`mcp__*`), or a
43 * regex written `/source/flags`.
44 */
45 tools: readonly string[]
46 /** Regex matched against each part of a shell command (Bash, Monitor). */
47 command?: string
48 /** Regex matched against the call's file path, relative to the project root. */
49 path?: string
50 /** Regex matched against the call's input as JSON. */
51 input?: string
52 /** A check in code (CHECKS) that must also hold. */
53 check?: CheckName
54 /** Who reviews a `review` match; Gandalf when absent. */
55 member?: MemberName
56 profile?: Profile
57 /** Plain-English reason, given to Claude and to you. Never the pattern. */
58 reason: string
59}
60
61export type Config = {
62 schemaVersion: 1
63 rules: readonly Rule[]
64 /** Ids of shipped non-block rules to switch off. */
65 disableRules: readonly string[]
66 /** Globs, relative to the project root: always the ask tier. */
67 protectedPaths: readonly string[]
68 /** Branch names or globs: force pushes to them are blocked. */
69 protectedBranches: readonly string[]
70 /** Regexes (case-insensitive) that make a target look like production. */
71 productionPatterns: readonly string[]
72 /** Per-slot model: an alias (`sonnet`, `opus`, `fable`) or a full id. */
73 models: Readonly<Partial<Record<ModelSlot, string>>>
74 /** The secrets scan's extra patterns and its allowlist. */
75 gollum: GollumConfig
76 /**
77 * What goes to the full council instead of one member: a rule id, a command
78 * regex written `/source/flags` (matched against each shell part), or a
79 * named check (BIG_CHECKS).
80 */
81 bigOperations: readonly string[]
82 /** The project's own checks the full council runs (tests, lint, typecheck). */
83 gimli: GimliConfig
84}
85
86/** Big-operation entries decided in code: a merge whose target is a protected branch. */
87export const BIG_CHECKS = ['merge-to-protected'] as const
88export type BigCheck = (typeof BIG_CHECKS)[number]
89
90export type GimliCommand = {
91 /** Shown to the user and to Claude (`tests`, `lint`). */
92 name: string
93 /** The command by its argument vector: no shell. */
94 argv: readonly string[]
95 /** How long it may run, in milliseconds (default 120 s, at most 600 s). */
96 timeoutMs: number
97}
98
99export type GimliConfig = {
100 commands: readonly GimliCommand[]
101}
102
103export const SECRET_LEVELS = ['high', 'low'] as const
104
105export type GollumPattern = {
106 id: string
107 level: (typeof SECRET_LEVELS)[number]
108 /** Regex source, matched globally against what the call would write or run. */
109 regex: string
110 /** What the finding is called in the dialog and refusal (`deploy key`). */
111 label: string
112}
113
114export type GollumConfig = {
115 patterns: readonly GollumPattern[]
116 /** Exact secret strings, or `sha256:` fingerprints the dialog shows. Never regexes. */
117 allowlist: readonly string[]
118}
119
120/** What the project overrides file may hold: every field optional. */
121export type Overrides = Partial<Omit<Config, 'schemaVersion' | 'gollum' | 'gimli'>> & {
122 schemaVersion: 1
123 gollum?: Partial<GollumConfig>
124 gimli?: Partial<GimliConfig>
125}
126
127export type RuleSource = 'shipped' | 'project'
128
129export const strictness = (tier: Tier): number => TIERS.indexOf(tier)
130
131export const stricter = (a: Tier, b: Tier): Tier =>
132 strictness(a) >= strictness(b) ? a : b
133hooks/config/write.ts 59 lines1import { validateOverrides } from './schema.js'
2import type { Rule } from './types.js'
3
4/**
5 * Edits to the project overrides file the mod makes itself, only after the
6 * user confirmed the exact entry. Pure: the file's text in, the new text out.
7 * A file that does not read as a valid overrides file is never rewritten, and
8 * an edit whose result does not validate is not made.
9 */
10
11export type FileEdit = { ok: true; text: string } | { ok: false; problem: string }
12
13type Raw = Record<string, unknown>
14
15const isObject = (value: unknown): value is Raw => typeof value === 'object' && value !== null && !Array.isArray(value)
16
17/**
18 * Reads the file (none, or blank: a new one), applies `change` to the parsed
19 * object, validates the result and writes it back pretty-printed. Spreading
20 * the parsed object keeps the user's key order; new keys go last.
21 */
22export function editOverrides(fileText: string | undefined, change: (raw: Raw) => Raw): FileEdit {
23 let raw: Raw
24 if (fileText === undefined || fileText.trim() === '') {
25 raw = { schemaVersion: 1 }
26 } else {
27 let parsed: unknown
28 try {
29 parsed = JSON.parse(fileText)
30 } catch {
31 return { ok: false, problem: 'the rules file is not valid JSON' }
32 }
33 if (!isObject(parsed)) return { ok: false, problem: 'the rules file is not a JSON object' }
34 raw = parsed
35 }
36 const before = validateOverrides(raw)
37 if (before.overrides === undefined) return { ok: false, problem: `the rules file has errors (${before.errors.join('; ')})` }
38 const next = change(raw)
39 const after = validateOverrides(next)
40 if (after.overrides === undefined) return { ok: false, problem: `the entry does not validate (${after.errors.join('; ')})` }
41 return { ok: true, text: `${JSON.stringify(next, null, 2)}\n` }
42}
43
44/** Adds one entry to `gollum.allowlist`, keeping the rest of the file and its key order. */
45export const withAllowlistEntry = (fileText: string | undefined, entry: string): FileEdit =>
46 editOverrides(fileText, raw => {
47 const gollum = isObject(raw.gollum) ? raw.gollum : {}
48 const allowlist = Array.isArray(gollum.allowlist) ? (gollum.allowlist as unknown[]) : []
49 return { ...raw, gollum: { ...gollum, allowlist: allowlist.includes(entry) ? allowlist : [...allowlist, entry] } }
50 })
51
52/**
53 * Adds one rule after the file's own rules: rules the user wrote decide
54 * first. An id already taken (the file changed since the rule was offered)
55 * fails validation, and nothing is written.
56 */
57export const withRule = (fileText: string | undefined, rule: Rule): FileEdit =>
58 editOverrides(fileText, raw => ({ ...raw, rules: [...(Array.isArray(raw.rules) ? (raw.rules as unknown[]) : []), rule] }))
59hooks/elrond/commands.ts 294 lines1import type { CouncilSession } from '../../types'
2import type { CompiledConfig } from '../config/schema.js'
3import { MEMBERS, MODEL_SLOTS, TIERS } from '../config/types.js'
4import type { MemberName, ModelSlot } from '../config/types.js'
5import { whoOf } from '../members/brief.js'
6import type { Classification } from '../rules/classify.js'
7import type { Big, CouncilSeat } from './council.js'
8import { councilSeatId } from './council.js'
9import { median } from '../state.js'
10import { text } from '../strings.js'
11import type { StringKey } from '../strings.js'
12import type { ModelChoice } from './models.js'
13import type { Operation } from './operations.js'
14import { attemptsOf } from './operations.js'
15import type { Route, Seat } from './routing.js'
16
17/**
18 * `/council`: one command, its subcommands parsed from the raw argument
19 * string, and its output built as lines. The output is for the user only:
20 * register.ts draws it in a pane or logs it, and never hands it to Claude.
21 */
22
23export type CouncilCommand =
24 | { kind: 'status' }
25 | { kind: 'bypass'; on: boolean }
26 | { kind: 'shadow'; on: boolean }
27 | { kind: 'log'; count: number }
28 | { kind: 'rules' }
29 | { kind: 'test'; command: string }
30 | { kind: 'models' }
31 | { kind: 'model'; slot: string; model: string; save: boolean }
32 | { kind: 'reload' }
33 | { kind: 'report' }
34 | { kind: 'debate' }
35 | { kind: 'usage'; key: StringKey; params?: Record<string, string> }
36
37export type Output = { title: string; lines: string[] }
38
39const DEFAULT_LOG = 10
40const MAX_LOG = 50
41
42/** Splits on spaces, keeping single- or double-quoted runs whole. */
43export function words(args: string): string[] {
44 const out: string[] = []
45 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
46 for (const match of args.matchAll(re)) out.push(match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : (match[2] ?? match[3] ?? ''))
47 return out
48}
49
50export function parseCouncil(args: string): CouncilCommand {
51 const trimmed = args.trim()
52 const [sub = '', ...rest] = words(trimmed)
53 switch (sub.toLowerCase()) {
54 case '':
55 case 'status':
56 return { kind: 'status' }
57 case 'on':
58 return { kind: 'bypass', on: false }
59 case 'off':
60 return { kind: 'bypass', on: true }
61 case 'shadow': {
62 const value = (rest[0] ?? '').toLowerCase()
63 return value === 'on' || value === 'off' ? { kind: 'shadow', on: value === 'on' } : { kind: 'usage', key: 'cmd.shadowUsage' }
64 }
65 case 'log': {
66 const n = Number(rest[0] ?? DEFAULT_LOG)
67 return { kind: 'log', count: Number.isInteger(n) && n > 0 ? Math.min(n, MAX_LOG) : DEFAULT_LOG }
68 }
69 case 'rules':
70 return { kind: 'rules' }
71 case 'test': {
72 // Everything after `test`, one layer of quotes removed: the command as typed.
73 const raw = trimmed.slice(trimmed.indexOf(sub) + sub.length).trim()
74 const command = /^(["'])([\s\S]*)\1$/.exec(raw)?.[2] ?? raw
75 return command === '' ? { kind: 'usage', key: 'cmd.testUsage' } : { kind: 'test', command }
76 }
77 case 'model':
78 case 'models': {
79 if (rest.length === 0) return { kind: 'models' }
80 const save = rest.includes('--save')
81 const [slot, model] = rest.filter(word => word !== '--save')
82 if (slot === undefined || model === undefined) return { kind: 'usage', key: 'cmd.modelUsage', params: { slots: MODEL_SLOTS.join(', ') } }
83 return { kind: 'model', slot: slot.toLowerCase(), model, save }
84 }
85 case 'reload':
86 return { kind: 'reload' }
87 case 'report':
88 return { kind: 'report' }
89 case 'debate':
90 return { kind: 'debate' }
91 default:
92 return { kind: 'usage', key: 'cmd.unknown', params: { sub } }
93 }
94}
95
96export const isSlot = (value: string): value is ModelSlot => (MODEL_SLOTS as readonly string[]).includes(value)
97
98export const WHO_OF_SLOT: Readonly<Record<ModelSlot, StringKey>> = {
99 gandalf: 'who.gandalf',
100 legolas: 'who.legolas',
101 aragorn: 'who.aragorn',
102 council: 'who.council',
103}
104
105export type StatusInput = {
106 session: CouncilSession
107 mode: 'enforcing' | 'shadow' | 'bypass'
108 members: Readonly<Record<MemberName, { enabled: boolean; choice: ModelChoice }>>
109 council: { enabled: boolean; choice: ModelChoice; sequential: boolean }
110 gimli: { enabled: boolean; commands: number }
111 gollumEnabled: boolean
112 galadrielEnabled: boolean
113 tokenBudget: number
114}
115
116const ZERO_COUNTS = { approved: 0, revised: 0, blocked: 0, failed: 0 }
117
118export function statusOutput(input: StatusInput): Output {
119 const { session } = input
120 const attempts = attemptsOf(session)
121 const typical = median(session.reviewMs)
122 const onOff = (on: boolean): string => text(on ? 'cmd.on' : 'cmd.off')
123 return {
124 title: text('cmd.title'),
125 lines: [
126 text('cmd.mode', { mode: text(`cmd.mode.${input.mode}`) }),
127 text('cmd.members'),
128 ...MEMBERS.map(member =>
129 text('cmd.member', {
130 who: text(whoOf(member)),
131 id: member,
132 state: onOff(input.members[member].enabled),
133 model: input.members[member].choice.model,
134 source: input.members[member].choice.source,
135 ...(session.counts[member] ?? ZERO_COUNTS),
136 }),
137 ),
138 text('cmd.council', {
139 who: text('who.fullCouncil'),
140 state: onOff(input.council.enabled),
141 model: input.council.choice.model,
142 source: input.council.choice.source,
143 order: text(input.council.sequential ? 'cmd.council.sequential' : 'cmd.council.parallel'),
144 ...(session.counts.council ?? ZERO_COUNTS),
145 }),
146 text('cmd.gimli', {
147 who: text('who.gimli'),
148 state: onOff(input.gimli.enabled),
149 count: input.gimli.commands,
150 ...(session.counts.gimli ?? ZERO_COUNTS),
151 }),
152 text('cmd.memberCode', { who: text('who.gollum'), state: onOff(input.gollumEnabled) }),
153 text('cmd.memberCode', { who: text('who.galadriel'), state: onOff(input.galadrielEnabled) }),
154 text('cmd.attempts', attempts),
155 text('cmd.tokens', { spent: session.tokensSpent, budget: input.tokenBudget }),
156 typical === undefined
157 ? text('cmd.noReviews')
158 : text('cmd.median', { time: `${(typical / 1000).toFixed(1)} s`, reviews: session.reviewMs.length }),
159 ],
160 }
161}
162
163/** The last `count` audit lines, newest last. Unparseable lines are skipped. */
164export function logOutput(logText: string, count: number): Output {
165 const records = logText
166 .split('\n')
167 .filter(line => line.trim() !== '')
168 .flatMap(line => {
169 try {
170 return [JSON.parse(line) as Record<string, unknown>]
171 } catch {
172 return []
173 }
174 })
175 .slice(-count)
176 if (records.length === 0) return { title: text('cmd.title'), lines: [text('cmd.logEmpty')] }
177 const str = (value: unknown, fallback = '-'): string => (typeof value === 'string' && value !== '' ? value : fallback)
178 return {
179 title: text('cmd.logTitle', { n: records.length }),
180 lines: records.map(record =>
181 text('cmd.logLine', {
182 ts: str(record.ts).replace('T', ' ').slice(0, 19),
183 tool: str(record.tool),
184 tier: str(record.tier),
185 who: str(record.member, record.tier === 'block' ? 'rules' : '-'),
186 verdict: str(record.verdict, '-') + (record.shadow === true ? ' (shadow)' : '') + (record.cached === true ? ' (cached)' : ''),
187 decision: typeof record.decision === 'string' ? `, you: ${record.decision}` : '',
188 outcome: str(record.outcome),
189 reason: str(record.reason, ''),
190 }),
191 ),
192 }
193}
194
195export function rulesOutput(compiled: CompiledConfig, origin: string, errors: readonly string[]): Output {
196 const rules = [...compiled.rules].sort((a, b) => TIERS.indexOf(b.tier) - TIERS.indexOf(a.tier))
197 const list = (key: StringKey, items: readonly string[]): string[] =>
198 items.length === 0 ? [] : [text('cmd.listLine', { name: text(key), items: items.join(', ') })]
199 return {
200 title: text('cmd.rulesTitle', { origin }),
201 lines: [
202 ...(errors.length > 0 ? [text('cmd.configErrors', { errors: errors.join('; ') })] : []),
203 ...rules.map(rule => text('cmd.ruleLine', { tier: rule.tier.padEnd(6), id: rule.id, source: rule.source, reason: rule.reason })),
204 ...list('cmd.list.protectedPaths', compiled.config.protectedPaths),
205 ...list('cmd.list.protectedBranches', compiled.config.protectedBranches),
206 ...list('cmd.list.production', compiled.config.productionPatterns),
207 ...list('cmd.list.secretPatterns', compiled.config.gollum.patterns.map(pattern => `${pattern.id} (${pattern.level})`)),
208 ...list('cmd.list.allowlist', compiled.config.gollum.allowlist.map(entry => (entry.startsWith('sha256:') ? entry : `${entry.slice(0, 3)}…`))),
209 ],
210 }
211}
212
213/** A seat as typed in config: `aragorn/git`, `gandalf`. */
214const seatId = (seat: Seat): string => (seat.profile !== undefined ? `${seat.member}/${seat.profile}` : seat.member)
215
216/** Who would review, for `/council test`: the route, and the model of the member it seats. */
217export type Reviewer = { route: Route; choice?: ModelChoice }
218
219/** A big operation, for `/council test`: who would sit, on which model, and the checks that would run. */
220export type CouncilPreview = {
221 big: Big
222 enabled: boolean
223 seats: readonly CouncilSeat[]
224 choice: ModelChoice
225 checks: readonly string[]
226 /** A merge counted as big without the current branch being checked. */
227 isBranchAssumed: boolean
228}
229
230const seatLine = (seat: CouncilSeat): string =>
231 seat.member === 'legolas' && seat.range !== undefined
232 ? text('cmd.seatRange', { who: text(whoOf(seat.member)), id: seat.member, kind: seat.range })
233 : text('cmd.seat', { who: text(whoOf(seat.member, seat.member === 'aragorn' ? seat.profile : undefined)), id: councilSeatId(seat) })
234
235function councilLines(council: CouncilPreview): string[] {
236 const who = text('who.fullCouncil')
237 if (council.seats.length === 0) return [text('cmd.testCouncilNobody', { entry: council.big.entry, who })]
238 return [
239 text('cmd.testCouncil', { who, entry: council.big.entry, model: council.choice.model, source: council.choice.source, seats: council.seats.map(seatLine).join('; ') }),
240 council.checks.length > 0
241 ? text('cmd.testCouncilChecks', { who: text('who.gimli'), names: council.checks.map(name => `"${name}"`).join(', ') })
242 : text('cmd.testCouncilNoChecks'),
243 ...(council.isBranchAssumed ? [text('cmd.testCouncilBranch', { who })] : []),
244 ]
245}
246
247export function testOutput(
248 command: string,
249 classification: Classification,
250 operation: Operation | undefined,
251 reviewer: Reviewer | undefined,
252 council?: CouncilPreview,
253): Output {
254 const decided = classification.decided
255 const lines = [text('cmd.testTier', { tier: classification.tier })]
256 if (decided === undefined) {
257 lines.push(text('cmd.testAllow'))
258 } else {
259 for (const finding of classification.findings) {
260 lines.push(text('cmd.testFinding', { subject: finding.subject, tier: finding.tier, rule: finding.ruleId, source: finding.source, reason: finding.reason }))
261 }
262 if (council?.enabled === true) {
263 lines.push(...councilLines(council))
264 } else if (classification.tier === 'review' && reviewer !== undefined) {
265 const { route } = reviewer
266 const wanted = route.wanted !== undefined ? `${text(whoOf(route.wanted.member, route.wanted.profile))} [${seatId(route.wanted)}]` : ''
267 if (route.kind === 'none') {
268 lines.push(text('cmd.testNoReviewer', { wanted }))
269 } else {
270 lines.push(
271 text('cmd.testReviewer', {
272 who: text(whoOf(route.member, route.profile)),
273 id: route.member,
274 profile: route.profile !== undefined ? `/${route.profile}` : '',
275 model: reviewer.choice?.model ?? '-',
276 source: reviewer.choice?.source ?? '-',
277 }),
278 )
279 if (route.fallback !== undefined) lines.push(text('cmd.testFallback', { wanted, why: text(`route.${route.fallback}`) }))
280 }
281 if (council !== undefined) lines.push(text('cmd.testCouncilOff', { entry: council.big.entry, who: text('who.fullCouncil') }))
282 }
283 if (operation !== undefined) lines.push(text('cmd.testOperation', { key: operation.key }))
284 }
285 return { title: text('cmd.testTitle', { command: command.length > 60 ? `${command.slice(0, 59)}…` : command }), lines }
286}
287
288export function modelsOutput(choices: Readonly<Record<ModelSlot, ModelChoice>>): Output {
289 return {
290 title: text('cmd.modelTitle'),
291 lines: MODEL_SLOTS.map(slot => text('cmd.modelLine', { id: slot, model: choices[slot].model, source: choices[slot].source })),
292 }
293}
294hooks/elrond/combine.ts 126 lines1import type { MemberName, Profile } from '../config/types.js'
2import { isGimliBlock } from '../members/gimli.js'
3import type { GimliRun } from '../members/gimli.js'
4import { VERDICTS } from '../members/shared.js'
5import type { Verdict, VerdictValue } from '../members/shared.js'
6import { text } from '../strings.js'
7import type { StringKey } from '../strings.js'
8import type { MemberOpinion } from './escalation.js'
9
10/**
11 * Combining the full council: the strictest verdict wins (block, then revise,
12 * then approve). A member that errored, timed out or answered malformed
13 * counts as a block from that member; a check that failed, timed out or
14 * could not start is a block too. Each reason is labelled with who gave it.
15 * Members never see each other's verdicts: this runs after all of them.
16 * Claude reads the reason and alternative, so every string here is asked for
17 * in plain mode, whatever the session's mode.
18 */
19
20/** One model member's part in the council. */
21export type Voice =
22 | { kind: 'verdict'; who: StringKey; member: MemberName; profile?: Profile; verdict: Verdict }
23 /** No verdict: an error, a timeout, a malformed reply. Counts as a block. */
24 | { kind: 'failed'; who: StringKey; member: MemberName; profile?: Profile; problem: string }
25 /** Never asked: the sequential council stopped at an earlier block, or there was nothing to review. */
26 | { kind: 'skipped'; who: StringKey; member: MemberName; profile?: Profile; why: string }
27
28export type Combined = {
29 verdict: VerdictValue
30 /** Every voice that didn't approve, labelled, one per line: the reason Claude and the user read. */
31 reason: string
32 /** The same without any check's output: what the audit log keeps. */
33 summary: string
34 /** The safer alternatives of those voices, labelled. */
35 alternative: string
36 /** Blocked only because members gave no verdict: no member blocked or asked to revise, and every check passed. */
37 isFailureOnly: boolean
38 /** How many model members gave a verdict or failed; zero means nobody reviewed. */
39 reviewed: number
40 /** Each voice and check, for the question put to the user. */
41 opinions: MemberOpinion[]
42}
43
44const rank = (verdict: VerdictValue): number => VERDICTS.indexOf(verdict)
45
46const valueOf = (voice: Voice): VerdictValue | undefined =>
47 voice.kind === 'verdict' ? voice.verdict.verdict : voice.kind === 'failed' ? 'block' : undefined
48
49/** What a check run says, in a line. */
50export function runLine(run: GimliRun): string {
51 switch (run.status) {
52 case 'passed':
53 return text('gimli.passed', { name: run.name }, 'plain')
54 case 'failed':
55 return run.code === null || run.code === undefined
56 ? text('gimli.killed', { name: run.name, signal: run.signal ?? 'a signal' }, 'plain')
57 : text('gimli.failed', { name: run.name, code: run.code }, 'plain')
58 case 'timed-out':
59 return text('gimli.timedOut', { name: run.name, seconds: Math.round(run.ms / 1000) }, 'plain')
60 case 'error':
61 return text('gimli.error', { name: run.name }, 'plain')
62 case 'stopped':
63 return text('gimli.stopped', { name: run.name }, 'plain')
64 }
65}
66
67const withTail = (run: GimliRun): string => (run.tail === '' ? runLine(run) : `${runLine(run)} ${text('gimli.tail', { tail: run.tail }, 'plain')}`)
68
69export function combine(voices: readonly Voice[], runs: readonly GimliRun[]): Combined {
70 let verdict: VerdictValue = 'approve'
71 const reasons: string[] = []
72 const summaries: string[] = []
73 const alternatives: string[] = []
74 let isRealBlock = false
75 for (const voice of voices) {
76 const value = valueOf(voice)
77 if (value === undefined || value === 'approve') continue
78 if (rank(value) > rank(verdict)) verdict = value
79 const who = text(voice.who, {}, 'plain')
80 if (voice.kind === 'verdict') {
81 isRealBlock = true
82 reasons.push(text('council.voice', { who, verdict: value, reason: voice.verdict.reason }, 'plain'))
83 summaries.push(reasons.at(-1) as string)
84 alternatives.push(text('council.voice', { who, verdict: value, reason: voice.verdict.safer_alternative }, 'plain'))
85 } else if (voice.kind === 'failed') {
86 reasons.push(text('council.noVerdict', { who, problem: voice.problem }, 'plain'))
87 summaries.push(reasons.at(-1) as string)
88 }
89 }
90 const failedRuns = runs.filter(isGimliBlock)
91 if (failedRuns.length > 0) {
92 verdict = 'block'
93 isRealBlock = true
94 for (const run of failedRuns) {
95 reasons.push(text('council.voice', { who: text('who.gimli', {}, 'plain'), verdict: 'block', reason: withTail(run) }, 'plain'))
96 summaries.push(text('council.voice', { who: text('who.gimli', {}, 'plain'), verdict: 'block', reason: runLine(run) }, 'plain'))
97 }
98 alternatives.push(text('alternative.gimli', { names: failedRuns.map(run => `"${run.name}"`).join(', ') }, 'plain'))
99 }
100 if (verdict !== 'approve' && alternatives.length === 0) alternatives.push(text('alternative.ask', {}, 'plain'))
101
102 const opinions: MemberOpinion[] = [
103 ...voices.flatMap((voice): MemberOpinion[] =>
104 voice.kind === 'verdict'
105 ? [{ who: voice.who, verdict: voice.verdict.verdict, reason: voice.verdict.reason }]
106 : voice.kind === 'failed'
107 ? [{ who: voice.who, problem: voice.problem }]
108 : [],
109 ),
110 ...runs.map((run): MemberOpinion => ({ who: 'who.gimli', verdict: isGimliBlock(run) ? 'block' : 'approve', reason: runLine(run) })),
111 ]
112 return {
113 verdict,
114 reason: reasons.map(line => `\n- ${line}`).join(''),
115 summary: summaries.join(' '),
116 alternative: alternatives.length === 1 ? (alternatives[0] as string) : alternatives.map(line => `\n- ${line}`).join(''),
117 isFailureOnly: verdict === 'block' && !isRealBlock,
118 reviewed: voices.filter(voice => voice.kind !== 'skipped').length,
119 opinions,
120 }
121}
122
123/** Whether the council has already blocked for real, so a check still running can no longer matter. */
124export const hasRealBlock = (voices: readonly Voice[]): boolean =>
125 voices.some(voice => voice.kind === 'verdict' && voice.verdict.verdict === 'block')
126hooks/elrond/council.ts 115 lines1import type { BigOperation, CompiledConfig } from '../config/schema.js'
2import type { Profile } from '../config/types.js'
3import { FILE_PATH_FIELDS } from '../rules/classify.js'
4import type { Call, Classification, Finding } from '../rules/classify.js'
5import type { ShellPart } from '../rules/shell.js'
6import type { Enabled } from './routing.js'
7import { seatOf } from './routing.js'
8
9/**
10 * The full council: which review-tier calls are big operations, and who sits
11 * for one. Pure: the current branch, the one fact a rule cannot see, comes in
12 * from register.ts.
13 */
14
15/** Why a call is a big operation: the config entry that matched, and the part it matched. */
16export type Big = { entry: string; subject: string }
17
18/** What decides a big operation besides the call: the current branch, when a merge needs it. */
19export type BigContext = {
20 /** The checked-out branch; undefined when unknown (a merge into it then counts as big). */
21 currentBranch?: string
22}
23
24const isGit = (part: ShellPart, sub: string): boolean => part.coreWords[0] === 'git' && part.coreWords[1] === sub
25
26/** A merge lands on the checked-out branch; a pull request merge on a base the command doesn't name. */
27const isMerge = (part: ShellPart): boolean =>
28 (isGit(part, 'merge') && !part.coreWords.slice(2).some(word => ['--abort', '--quit', '--continue'].includes(word))) ||
29 (part.coreWords[0] === 'gh' && part.coreWords[1] === 'pr' && part.coreWords[2] === 'merge')
30
31/** Whether working out a big operation needs the current branch: only a `git merge` does. */
32export const needsCurrentBranch = (classification: Classification, compiled: CompiledConfig): boolean =>
33 compiled.bigOperations.some(big => big.kind === 'check') &&
34 classification.findings.some(finding => finding.part !== undefined && isGit(finding.part, 'merge'))
35
36function matches(big: BigOperation, finding: Finding, compiled: CompiledConfig, context: BigContext): boolean {
37 if (big.kind === 'rule') return finding.ruleId === big.ruleId
38 const part = finding.part
39 if (part === undefined) return false
40 if (big.kind === 'command') return big.re.test(part.core)
41 // merge-to-protected: an unknown current branch counts, since the merge may land on a protected one.
42 if (!isMerge(part)) return false
43 if (part.coreWords[0] === 'gh') return true
44 const branch = context.currentBranch
45 return branch === undefined || branch === 'HEAD' || compiled.protectedBranches.some(re => re.test(branch))
46}
47
48/**
49 * The big operation a review-tier call is, if any: the first configured entry
50 * one of its review parts matches. Other tiers never reach the council.
51 */
52export function bigOperationOf(classification: Classification, compiled: CompiledConfig, context: BigContext = {}): Big | undefined {
53 if (classification.tier !== 'review') return undefined
54 for (const finding of classification.findings) {
55 if (finding.tier !== 'review') continue
56 const big = compiled.bigOperations.find(entry => matches(entry, finding, compiled, context))
57 if (big !== undefined) return { entry: big.entry, subject: finding.subject }
58 }
59 return undefined
60}
61
62/**
63 * One seat at the full council. The diff reviewer reviews a file tool's own
64 * diff, or the changes a push or merge would send or bring in (`range`).
65 */
66export type CouncilSeat =
67 | { member: 'gandalf' }
68 | { member: 'legolas'; range?: 'push' | 'merge' }
69 | { member: 'aragorn'; profile: Profile }
70
71/** The push or merge a diff reviewer could read the changes of, if the call has one. */
72export function rangeOf(classification: Classification): { kind: 'push' | 'merge'; part: ShellPart } | undefined {
73 for (const finding of classification.findings) {
74 const part = finding.part
75 if (part === undefined || finding.tier !== 'review') continue
76 if (isGit(part, 'push')) return { kind: 'push', part }
77 if (isGit(part, 'merge') && isMerge(part)) return { kind: 'merge', part }
78 }
79 return undefined
80}
81
82/**
83 * Who sits for a big operation: every enabled model member with something of
84 * this call to review. The general reviewer always; the git and database
85 * reviewer once per profile the call's parts ask for; the diff reviewer for a
86 * file change, or for a push or merge (the changes it would send or bring in,
87 * read by a fixed `git diff`, so only while the read-only preview is on).
88 * Each gets its own brief; none sees another's verdict.
89 */
90export function councilSeats(call: Call, classification: Classification, enabled: Enabled, options: { ranges: boolean }): CouncilSeat[] {
91 const seats: CouncilSeat[] = []
92 if (enabled.gandalf) seats.push({ member: 'gandalf' })
93 if (enabled.legolas) {
94 if (FILE_PATH_FIELDS[call.tool] !== undefined) seats.push({ member: 'legolas' })
95 else if (options.ranges) {
96 const range = rangeOf(classification)
97 if (range !== undefined) seats.push({ member: 'legolas', range: range.kind })
98 }
99 }
100 if (enabled.aragorn) {
101 const profiles = new Set<Profile>()
102 for (const finding of classification.findings) {
103 if (finding.tier !== 'review') continue
104 const seat = seatOf(finding)
105 if (seat.member === 'aragorn' && seat.profile !== undefined) profiles.add(seat.profile)
106 }
107 for (const profile of ['git', 'database'] as const) if (profiles.has(profile)) seats.push({ member: 'aragorn', profile })
108 }
109 return seats
110}
111
112/** A seat as typed in config and shown by `/council test`: `aragorn/git`, `gandalf`. */
113export const councilSeatId = (seat: CouncilSeat): string =>
114 seat.member === 'aragorn' ? `${seat.member}/${seat.profile}` : seat.member
115hooks/elrond/escalation.ts 82 lines1import { truncate } from '../members/shared.js'
2import { redact } from '../redact.js'
3import { text } from '../strings.js'
4import type { Mode, StringKey } from '../strings.js'
5
6/**
7 * The escalation: the question put to the user, and what their answer means.
8 * The dialog is the engine's AskUserQuestion: two labelled options and a
9 * free-text "Other", which reaches Claude as an instruction.
10 */
11
12export type MemberOpinion =
13 | { who: StringKey; verdict: string; reason: string }
14 | { who: StringKey; problem: string }
15
16export type Question = {
17 call: string
18 ruleReason: string
19 why: StringKey
20 opinions: readonly MemberOpinion[]
21 /** The read-only preview, when there is one. */
22 preview?: string
23 /** Possible secrets the scan found, already redacted. */
24 secrets?: readonly { label: string; snippet: string }[]
25 /** The dialog offers the allowlist (one finding exactly). */
26 canAllowlist?: boolean
27}
28
29export type Answer =
30 | { kind: 'allow-once' }
31 | { kind: 'keep-blocked' }
32 | { kind: 'allowlist' }
33 | { kind: 'instruction'; text: string }
34
35export type Unanswered = 'dismissed' | 'chat' | 'unavailable'
36
37const MAX_QUESTION_CHARS = 2_500
38
39const PREVIEW_LINES = 15
40
41/** The dialog's labels; a secrets question adds the allowlist. */
42export const optionsOf = (mode: Mode = 'plain', withAllowlist = false): string[] => [
43 text('ask.allowOnce', {}, mode),
44 ...(withAllowlist ? [text('ask.allowlist', {}, mode)] : []),
45 text('ask.keepBlocked', {}, mode),
46]
47
48export function questionText(question: Question, mode: Mode = 'plain'): string {
49 const lines = [
50 text('ask.title', {}, mode),
51 text(question.why, {}, mode),
52 text('ask.call', { call: truncate(redact(question.call), 12, 600) }, mode),
53 ...(question.ruleReason !== '' ? [text('ask.rule', { reason: question.ruleReason }, mode)] : []),
54 ...(question.secrets ?? []).map(secret =>
55 text('ask.secret', { label: secret.label, snippet: redact(secret.snippet) }, mode),
56 ),
57 ...question.opinions.map(opinion =>
58 'problem' in opinion
59 ? text('ask.failed', { who: text(opinion.who, {}, mode), problem: opinion.problem }, mode)
60 : text('ask.verdict', { who: text(opinion.who, {}, mode), verdict: opinion.verdict, reason: redact(opinion.reason) }, mode),
61 ),
62 ...(question.preview !== undefined
63 ? [text('ask.preview', { preview: truncate(redact(question.preview), PREVIEW_LINES, 900) }, mode)]
64 : []),
65 text(question.canAllowlist === true ? 'ask.closeSecret' : 'ask.close', {}, mode),
66 ]
67 const joined = lines.join('\n')
68 return joined.length > MAX_QUESTION_CHARS ? `${joined.slice(0, MAX_QUESTION_CHARS - 1)}…` : joined
69}
70
71/** Labels compare exactly; anything else the user typed is an instruction. */
72export function interpretAnswer(answer: string, mode: Mode = 'plain', withAllowlist = false): Answer {
73 if (answer === text('ask.allowOnce', {}, mode)) return { kind: 'allow-once' }
74 if (answer === text('ask.keepBlocked', {}, mode)) return { kind: 'keep-blocked' }
75 if (withAllowlist && answer === text('ask.allowlist', {}, mode)) return { kind: 'allowlist' }
76 return { kind: 'instruction', text: answer.trim() }
77}
78
79/** Why `$.ui.ask` rejected: the user chose to chat, or dismissed it. */
80export const interpretRejection = (error: unknown): Unanswered =>
81 /chat/i.test(error instanceof Error ? error.message : String(error)) ? 'chat' : 'dismissed'
82hooks/elrond/models.ts 53 lines1import { BUILT_IN_MODELS } from '../config/defaults.js'
2import type { ModelSlot } from '../config/types.js'
3
4/**
5 * Which model a slot runs on, from the first layer that sets one: a session
6 * switch, the user's /config row, the project rules file, the built-in.
7 */
8
9export type ModelSource = 'session' | 'settings' | 'project' | 'built-in'
10
11export type ModelChoice = { model: string; source: ModelSource }
12
13export type ModelLayers = {
14 session?: string
15 /** The /config row's value; `default` means unset. */
16 settings?: string
17 project?: string
18}
19
20const isSet = (value: string | undefined): value is string =>
21 value !== undefined && value.trim() !== '' && value !== 'default'
22
23export function resolveModel(slot: ModelSlot, layers: ModelLayers): ModelChoice {
24 if (isSet(layers.session)) return { model: layers.session, source: 'session' }
25 if (isSet(layers.settings)) return { model: layers.settings, source: 'settings' }
26 if (isSet(layers.project)) return { model: layers.project, source: 'project' }
27 return { model: BUILT_IN_MODELS[slot], source: 'built-in' }
28}
29
30/**
31 * The request settings that follow from a model, so choosing a model is the
32 * only decision. Thinking models spend output tokens thinking, so their cap
33 * leaves room; an unknown id is treated as a thinking model.
34 */
35export type ModelProfile = {
36 maxTokens: number
37 effort?: 'low'
38 deadlineMs: number
39}
40
41export function profileOf(model: string): ModelProfile {
42 const name = model.toLowerCase()
43 if (name.includes('haiku')) return { maxTokens: 400, deadlineMs: 20_000 }
44 if (name.includes('sonnet')) return { maxTokens: 2_000, effort: 'low', deadlineMs: 30_000 }
45 if (name.includes('fable') || name.includes('mythos')) return { maxTokens: 4_000, effort: 'low', deadlineMs: 90_000 }
46 if (name.includes('opus')) return { maxTokens: 2_000, effort: 'low', deadlineMs: 45_000 }
47 return { maxTokens: 2_000, effort: 'low', deadlineMs: 45_000 }
48}
49
50/** The deadline for one review: the user's override in seconds (0: from the model). */
51export const deadlineFor = (model: string, overrideSeconds: number): number =>
52 overrideSeconds > 0 ? overrideSeconds * 1000 : profileOf(model).deadlineMs
53hooks/elrond/operations.ts 298 lines1import { redact } from '../redact.js'
2import { FILE_PATH_FIELDS, SHELL_TOOLS } from '../rules/classify.js'
3import type { Call, Classification } from '../rules/classify.js'
4import { relativeTo, resolve } from '../rules/paths.js'
5import type { ShellPart } from '../rules/shell.js'
6
7/**
8 * Operations: what a call is trying to do, so a rephrased retry counts
9 * against the same attempt. The key is tool family + verb + normalised
10 * targets; the verb key drops the targets, to catch retries that change them.
11 */
12
13export type OperationContext = {
14 root: string
15 cwd: string
16 home?: string
17 /** A file tool's path with symbolic links resolved, when known. */
18 realPath?: string
19}
20
21export type Operation = { key: string; verbKey: string }
22
23/** Programs whose first word after the name is a subcommand that names the verb. */
24const SUBCOMMAND_PROGRAMS: ReadonlySet<string> = new Set([
25 'git', 'npm', 'pnpm', 'yarn', 'bun', 'cargo', 'docker', 'docker-compose', 'podman', 'kubectl', 'helm',
26 'terraform', 'tofu', 'gh', 'aws', 'gcloud', 'az', 'prisma', 'knex', 'alembic', 'rails', 'rake',
27 'flyway', 'liquibase', 'goose', 'dbmate', 'atlas', 'drizzle-kit', 'sequelize', 'typeorm', 'gem', 'twine',
28 'systemctl', 'launchctl', 'brew', 'apt', 'apt-get', 'dnf', 'yum', 'pip', 'pip3', 'uv', 'poetry',
29])
30
31/** Programs whose arguments are all paths, resolved so `./build` and `build/` match. */
32const PATH_PROGRAMS: ReadonlySet<string> = new Set([
33 'rm', 'rmdir', 'unlink', 'shred', 'srm', 'trash', 'mv', 'cp', 'ln', 'chmod', 'chown', 'chgrp',
34 'truncate', 'touch', 'mkdir', 'tee', 'find', 'sed', 'perl', 'ruby', 'bash', 'sh', 'zsh', 'source', '.',
35 'python', 'python3', 'node', 'deno', 'scp', 'rsync',
36])
37
38const SQL_CLIENTS: ReadonlySet<string> = new Set([
39 'psql', 'pg_restore', 'dropdb', 'createdb', 'mysql', 'mariadb', 'mysqladmin', 'sqlite3', 'mongo', 'mongosh',
40 'mongorestore', 'redis-cli', 'cqlsh', 'sqlcmd', 'clickhouse', 'clickhouse-client',
41])
42
43/** Options of SQL clients that name the database or its host. */
44const SQL_TARGET_OPTIONS: ReadonlySet<string> = new Set(['-d', '--dbname', '-h', '--host', '-D', '--database', '--db', '-n'])
45
46const MAX_TARGETS = 8
47const MAX_KEY_CHARS = 300
48
49const unquote = (word: string): string => word.replace(/^['"]|['"]$/g, '')
50
51const programOf = (part: ShellPart): string => {
52 const first = part.coreWords[0] ?? ''
53 return first.slice(first.lastIndexOf('/') + 1)
54}
55
56/** A path as the key spells it: relative to the root when inside it, no trailing slash. */
57function normalPath(word: string, cwd: string, context: OperationContext): string {
58 const absolute = resolve(unquote(word), cwd, context.home)
59 const relative = relativeTo(absolute, context.root)
60 const out = relative ?? absolute
61 return out === '' ? '.' : out.replace(/\/+$/, '') || '/'
62}
63
64const positionals = (words: readonly string[]): string[] => words.filter(word => !word.startsWith('-') && word !== '')
65
66/** `git push [options] [remote] [refspec...]`: remote plus destination branches. */
67function pushTargets(args: readonly string[]): string[] {
68 const words = positionals(args)
69 const remote = words[0] ?? '(default remote)'
70 const branches = words.slice(1).map(refspec => {
71 const destination = refspec.includes(':') ? refspec.slice(refspec.indexOf(':') + 1) : refspec
72 return destination.replace(/^\+/, '').replace(/^refs\/heads\//, '')
73 })
74 return [remote, ...(branches.length > 0 ? branches : ['(current branch)'])]
75}
76
77/** The database a SQL client talks to: option values, else its first positional. */
78function sqlTargets(args: readonly string[]): string[] {
79 const out: string[] = []
80 for (let i = 0; i < args.length; i++) {
81 const word = args[i] ?? ''
82 const eq = word.indexOf('=')
83 if (eq > 0 && SQL_TARGET_OPTIONS.has(word.slice(0, eq))) out.push(word.slice(eq + 1))
84 else if (SQL_TARGET_OPTIONS.has(word) && args[i + 1] !== undefined) out.push(args[++i] ?? '')
85 else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(word)) out.push(word)
86 }
87 if (out.length === 0) {
88 const first = positionals(args)[0]
89 if (first !== undefined) out.push(first)
90 }
91 return out.map(target => redact(unquote(target)))
92}
93
94/** Files a part's redirects write to (not `2>&1`, not /dev/null). */
95const writtenFiles = (part: ShellPart, cwd: string, context: OperationContext): string[] =>
96 part.redirects
97 .filter(redirect => redirect.op.includes('>') && !redirect.target.startsWith('&') && redirect.target !== '/dev/null')
98 .map(redirect => normalPath(redirect.target, cwd, context))
99
100/** One shell part's verb and targets. */
101export function partOperation(part: ShellPart, cwd: string, context: OperationContext): { verb: string; targets: string[] } {
102 const program = programOf(part)
103 const args = part.coreWords.slice(1)
104 if (program === 'git') {
105 // `git -C dir push`: skip global options to find the subcommand.
106 let index = 0
107 while (index < args.length && (args[index] ?? '').startsWith('-')) index += ['-C', '-c'].includes(args[index] ?? '') ? 2 : 1
108 const sub = args[index] ?? ''
109 const rest = args.slice(index + 1)
110 if (sub === 'push') return { verb: 'git push', targets: pushTargets(rest) }
111 return { verb: `git ${sub}`.trim(), targets: positionals(rest).map(unquote) }
112 }
113 if (SQL_CLIENTS.has(program)) return { verb: program, targets: sqlTargets(args) }
114 if (SUBCOMMAND_PROGRAMS.has(program)) {
115 const sub = positionals(args)[0]
116 const rest = sub === undefined ? [] : args.slice(args.indexOf(sub) + 1)
117 return { verb: sub === undefined ? program : `${program} ${sub}`, targets: positionals(rest).map(unquote) }
118 }
119 if (PATH_PROGRAMS.has(program)) {
120 return { verb: program, targets: [...positionals(args).map(word => normalPath(word, cwd, context)), ...writtenFiles(part, cwd, context)] }
121 }
122 return { verb: program, targets: [...positionals(args).map(unquote), ...writtenFiles(part, cwd, context)] }
123}
124
125const cap = (key: string): string => (key.length > MAX_KEY_CHARS ? `${key.slice(0, MAX_KEY_CHARS - 1)}…` : key)
126
127const sortedUnique = (items: readonly string[]): string[] => [...new Set(items)].sort()
128
129/**
130 * The operation a gated call attempts. Shell: each gated part's verb and
131 * targets (flags dropped, paths resolved, targets sorted); file tools share
132 * the `file` family keyed on the real path; anything else is its tool.
133 */
134export function operationOf(call: Call, classification: Classification, context: OperationContext): Operation {
135 if (SHELL_TOOLS.has(call.tool)) {
136 const parts = classification.findings.filter(finding => finding.part !== undefined)
137 if (parts.length === 0) {
138 const text = typeof call.input.command === 'string' ? call.input.command.replace(/\s+/g, ' ').trim() : ''
139 return { key: cap(`shell:${redact(text)}`), verbKey: 'shell:(unparsed)' }
140 }
141 const ops = parts.map(finding => partOperation(finding.part as ShellPart, finding.cwd ?? context.cwd, context))
142 const verbs = sortedUnique(ops.map(op => op.verb))
143 const key = sortedUnique(ops.map(op => `${op.verb} ${sortedUnique(op.targets).slice(0, MAX_TARGETS).join(' ')}`.trim()))
144 // The key can hold a target a command named verbatim (a push URL with a
145 // token, a connection string); it is written to the audit log, so redact.
146 return { key: cap(redact(`shell:${key.join(' ; ')}`)), verbKey: cap(`shell:${verbs.join(' ; ')}`) }
147 }
148 const field = FILE_PATH_FIELDS[call.tool]
149 const given = field === undefined ? undefined : call.input[field]
150 if (typeof given === 'string') {
151 const path = context.realPath !== undefined ? normalPath(context.realPath, '/', context) : normalPath(given, context.cwd, context)
152 return { key: cap(`file:${path}`), verbKey: `file:${call.tool}` }
153 }
154 return { key: cap(`tool:${call.tool}`), verbKey: cap(`tool:${call.tool}`) }
155}
156
157// ── Rounds, failed attempts, lockout ────────────────────────────────────────
158
159export const ROUND_CAP = 2
160export const KEY_WIPE_CAP = 3
161export const VERB_WIPE_CAP = 5
162
163export type OpState = { rounds: number; wipes: number }
164
165export type OpsState = {
166 ops: Readonly<Record<string, OpState>>
167 verbWipes: Readonly<Record<string, number>>
168}
169
170const MAX_OPS = 200
171
172const ZERO: OpState = { rounds: 0, wipes: 0 }
173
174export const opOf = (state: OpsState, key: string): OpState => state.ops[key] ?? ZERO
175
176/** Keeps the newest MAX_OPS entries, so a long prompt cannot grow state without bound. */
177function withOp<T extends OpsState>(state: T, key: string, op: OpState): T {
178 const { [key]: _old, ...rest } = state.ops
179 const entries = Object.entries({ ...rest, [key]: op })
180 return { ...state, ops: Object.fromEntries(entries.slice(-MAX_OPS)) }
181}
182
183export type Lockout = { kind: 'key' | 'verb'; wipes: number } | undefined
184
185/** Whether the operation is locked out: 3 failed attempts on its key, or 5 on its verb. */
186export function lockoutOf(state: OpsState, operation: Operation): Lockout {
187 const key = opOf(state, operation.key).wipes
188 if (key >= KEY_WIPE_CAP) return { kind: 'key', wipes: key }
189 const verb = state.verbWipes[operation.verbKey] ?? 0
190 if (verb >= VERB_WIPE_CAP) return { kind: 'verb', wipes: verb }
191 return undefined
192}
193
194/** One review verdict on the operation: a non-approve verdict uses a round. */
195export function noteRound<T extends OpsState>(state: T, key: string, isApprove: boolean): T {
196 if (isApprove) return state
197 const op = opOf(state, key)
198 return withOp(state, key, { ...op, rounds: op.rounds + 1 })
199}
200
201export const roundsLeft = (state: OpsState, key: string): number => Math.max(0, ROUND_CAP - opOf(state, key).rounds)
202
203/**
204 * Failed attempts since the user last wrote, for the status and the debate
205 * pane: how many (the per-verb counter, which only a new prompt clears), over
206 * how many operations, and how many of those are locked out.
207 */
208export function attemptsOf(state: OpsState): { count: number; ops: number; locked: number } {
209 const ops = Object.values(state.ops)
210 return {
211 count: Object.values(state.verbWipes).reduce((sum, n) => sum + n, 0),
212 ops: ops.length,
213 locked: ops.filter(op => op.wipes >= KEY_WIPE_CAP).length,
214 }
215}
216
217/** Out of rounds: the next attempt goes to the user, with no model call. */
218export const isOutOfRounds = (state: OpsState, key: string): boolean => opOf(state, key).rounds >= ROUND_CAP
219
220/** A failed attempt, counted on the key and on its verb. */
221export function noteWipe<T extends OpsState>(state: T, operation: Operation): T {
222 const op = opOf(state, operation.key)
223 const counted = withOp(state, operation.key, { ...op, wipes: op.wipes + 1 })
224 return { ...counted, verbWipes: { ...counted.verbWipes, [operation.verbKey]: (counted.verbWipes[operation.verbKey] ?? 0) + 1 } }
225}
226
227/** The user typed an instruction: the operation's rounds start over. */
228export function resetRounds<T extends OpsState>(state: T, key: string): T {
229 const op = opOf(state, key)
230 return op.rounds === 0 ? state : withOp(state, key, { ...op, rounds: 0 })
231}
232
233/** The call ran and succeeded: its own operation starts over (the verb counter stays). */
234export function resetOperation<T extends OpsState>(state: T, key: string): T {
235 if (state.ops[key] === undefined) return state
236 const { [key]: _done, ...rest } = state.ops
237 return { ...state, ops: rest }
238}
239
240// ── What happened to the call, and whether it counts as a failed attempt ────
241
242export type Outcome = 'ran' | 'error' | 'refused' | 'refused-by-user' | 'denied-by-permission'
243
244/**
245 * Claude Code's words when the person refuses a call at its own permission
246 * prompt (with or without feedback). Read from the 2.1.289 binary.
247 */
248const USER_REFUSAL: readonly RegExp[] = [
249 /the user doesn'?t want to proceed with this tool use/i,
250 /the user doesn'?t want to take this action/i,
251]
252
253/**
254 * Claude Code's words when its permission check stops a call with nobody
255 * asked: no one to ask (`-p`), or a deny rule. The first is verified live.
256 */
257const AUTOMATIC_DENIAL: readonly RegExp[] = [
258 /needs? approval/i,
259 // Seen on 2.1.294: a `git push` in `-p` ("This command requires approval").
260 /\brequires? approval\b/i,
261 /requested permissions? to (use|write|edit|read|run)/i,
262 /haven'?t granted it yet/i,
263 /permission to use .+ has been denied/i,
264]
265
266export const isUserRefusal = (text: string): boolean => USER_REFUSAL.some(re => re.test(text))
267
268export const isAutomaticDenial = (text: string): boolean => AUTOMATIC_DENIAL.some(re => re.test(text))
269
270export function outcomeOf(result: { deny?: string; isError?: boolean; text?: string }): Outcome {
271 if (result.deny !== undefined) return 'refused'
272 if (result.isError !== true) return 'ran'
273 const text = result.text ?? ''
274 if (isUserRefusal(text)) return 'refused-by-user'
275 return isAutomaticDenial(text) ? 'denied-by-permission' : 'error'
276}
277
278export type WipePolicy = {
279 /** A gated call that ran and errored counts (userConfig `toolErrorsAreWipes`). */
280 toolErrors: boolean
281}
282
283/**
284 * Whether a call that went through `next(e)` counts as a failed attempt. The
285 * person refusing at Claude Code's prompt always does, like "keep blocked";
286 * an automatic denial never does, since nobody chose it.
287 */
288export const isWipeOutcome = (outcome: Outcome, policy: WipePolicy): boolean =>
289 outcome === 'refused-by-user' || (outcome === 'error' && policy.toolErrors)
290
291// ── Cache ───────────────────────────────────────────────────────────────────
292
293const MAX_CACHE = 100
294
295/** Notes an approved fingerprint; the cache lives until the next prompt. */
296export const cacheApprove = (cache: readonly string[], fingerprint: string): string[] =>
297 [...cache.filter(known => known !== fingerprint), fingerprint].slice(-MAX_CACHE)
298