Rules that are enforced, not remembered: deny, ask, rewrite or note on any tool call, subagents included. A pixel-art trap for every mistake you already wrote…

▄▀▀▄ ▀█▀ █▀█ █ █▀█ █ █ █ █ █▀█ █▀▀
▄▀▀▀▀▄▄ █ █▀▄ █ █▀▀ ▀▄▀▄▀ █ █▀▄ ██▄
▀▀▀▀▀▀▀▀▀
▄▄▄▀▀▀▀▀▀▀▄▄ RULES THAT ARE ENFORCED, NOT REMEMBERED

Memories are advice, mods are law. A rule you wrote in CLAUDE.md or memory is something the model may forget. Tripwire checks it at the moment of the tool call, every call, subagents included.
The problem, in one line: across 189 memory files there were 405 "never / don't" lines, and at least 11 mistakes were repeated after they had been written down.
/plugin marketplace add pourya7/claude-code-mods
/plugin install tripwire@claude-code-mods
Then give it some rules. /tripwire init writes the starter pack to ~/.claude/tripwire.json (only if that file does not exist yet), or copy examples/tripwire.json yourself.
Rules live in two files, read in this order:
~/.claude/tripwire.json: yours, for every project<project>/.claude/tripwire.json: the project's, shared with the repo. It may hold deny, ask and note rules only: a rewrite rule there is reported as a bad rule and skipped, so a repository you clone can never change the commands you run. Put rewrite rules in your own file.They load when the session starts and again on /tripwire reload. A file is either an array of rules or { "rules": [...] }.
{
"id": "no-force-push",
"tool": "Bash",
"match": "\\bgit(?:\\s+(?:-[Cc]\\s+\\S+|-{1,2}\\w[\\w-]*(?:=\\S+)?))*\\s+push\\b[^;&|\\n]*\\s(?:(?:--force|-f)(?=\\s|$)|\\+[^\\s:+])",
"field": "command",
"action": "deny",
"message": "Force pushes rewrite shared history. Use --force-with-lease.",
"cite": "memory/never-force-push.md"
}
| Key | Meaning |
|---|---|
id | A unique name. A repeated id is skipped and reported. |
tool | A tool name (Bash, Edit, WebFetch, ...), a glob (mcp__*, mcp__*__merge), or * for every tool. |
match | A JavaScript regular expression tested against the field. |
field | Which input field to test (command for Bash, file_path for Edit/Write/Read, url for WebFetch). Left out, the whole tool input as JSON. |
action | deny, ask, rewrite or note (below). |
message | What the model reads when the rule fires. |
cite | Optional: where the rule was written down. Shown with the message. |
replace | rewrite only: the replacement text ($1 works). Every match in the field is replaced. A rewrite needs a field. |
Matching rules apply in file order, on every tool call, in the main session and in every subagent.
| Action | What happens |
|---|---|
deny | The call is refused. The model gets a TRAP SPRUNG! error with the id, message and cite, and a red band appears above your prompt. Later rules are not checked. |
ask | The call goes to a permission prompt (through tool.check), with the rule's message as the reason. It never turns a deny from your settings into an ask. |
rewrite | The field is rewritten before the call runs. Later rules see the rewritten input. It is never silent: the model reads tripwire rewrite [id]: <message> (command was: ... → now: ...) after the result (or after the error, if the rewritten call is refused), and you get a TRIPWIRE REWROTE BASH: <id> toast. User file only. |
note | The call runs, and the message is attached after the result as context only the model reads. |
A rule with a bad regex, an unknown action or a missing key is skipped, never fatal: you get one toast per load (TRIPWIRE: 2 BAD RULES SKIPPED · /tripwire), and the pane lists each problem.
examples/tripwire.json holds eight generic rules:
| id | action | catches | ||
|---|---|---|---|---|
no-force-push | deny | git push --force / -f and +refspec pushes such as git push origin +main, also behind global options (git -C dir push ...). A --force-with-lease push passes. | ||
no-verify | deny | --no-verify on commit, push, merge, rebase, am, cherry-pick, also behind global options (git -c k=v commit ...) | ||
no-admin-merge | deny | gh pr merge --admin | ||
no-checkout-discard | deny | git checkout -- <file>, with a hint to back the file up first | ||
no-rm-root | deny | rm of /, /*, ~, ~/ or $HOME | ||
no-curl-pipe-sh | deny | `curl ... \ | sh and wget ... \ | bash` |
checks-after-push | note | gh pr checks: a reminder that checks right after a push may belong to the previous commit | ||
protected-host | ask | a template: anything mentioning api.example.com. Change the host or delete it. |
| Command | What it does |
|---|---|
/tripwire | Opens the TRAPS ARMED: N pane. Each rule shows its id, action, hit count and last hit, with a DISARM button that turns it off for this session (REARM turns it back on). |
/tripwire list | The same list as text (for claude -p and surfaces without panes). |
/tripwire reload | Re-reads both rule files. |
/tripwire add <sentence> | Asks a model to turn a plain-English rule into rule JSON and shows it in the pane with ARM and DISCARD. Only ARM writes anything: it appends the rule to ~/.claude/tripwire.json and reloads. If the model's reply is not a valid rule, the pane shows why and offers nothing to arm. |
/tripwire init | Writes the starter pack to ~/.claude/tripwire.json if the file does not exist. It never overwrites. |
Hit counts are kept per rule id across sessions in the plugin's own store. Each hit is added to the count stored at that moment, so two sessions running at once add up rather than overwrite each other.
Set them in /config or under pluginConfigs.tripwire.options in settings.
| Option | Default | Meaning |
|---|---|---|
compileModel | haiku | The model /tripwire add asks for a proposal. |
band | true | Show the red TRAP SPRUNG! band above the prompt when a deny fires. Off, a deny shows a toast instead. |
In the terminal the sprites are drawn in PICO-8 colours: a red bomb with a lit orange-and-yellow fuse on a grey wire. This text capture loses the colours.
The band after a deny:
▄▀▀▄ TRAP SPRUNG! NO-FORCE-PUSH · BASH BLOCKED
▄▀▀▀▀▄▄ Force pushes rewrite shared history. Use --force-with-lease.
▀▀▀▀▀▀▀▀▀ CITE memory/never-force-push.md
▄▄▄▀▀▀▀▀▀▀▄▄ [ OK ]
The /tripwire pane:
▄▀▀▄ TRAPS ARMED: 7
▄▀▀▀▀▄ 8 USER · 0 PROJECT · 1 DISARMED
────────────────────────────────────────────────────────────
● NO-FORCE-PUSH DENY x3 14:02 [ DISARM ]
● NO-VERIFY DENY x0 --:-- [ DISARM ]
● NO-ADMIN-MERGE DENY x0 --:-- [ DISARM ]
● NO-CHECKOUT-DISCARD DENY x1 09:47 [ DISARM ]
● NO-RM-ROOT DENY x0 --:-- [ DISARM ]
● NO-CURL-PIPE-SH DENY x0 --:-- [ DISARM ]
● CHECKS-AFTER-PUSH NOTE x12 14:05 [ DISARM ]
○ PROTECTED-HOST OFF x0 --:-- [ REARM ]
PROPOSED RULE · NOT ARMED
"never deploy on a friday"
{
"id": "no-friday-deploy",
"tool": "Bash",
"match": "\\bdeploy\\b",
"field": "command",
"action": "ask",
"message": "Confirm a deploy."
}
[ ARM ] [ DISCARD ]
What the model reads when a deny fires:
▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
▌ TRAP SPRUNG! ▐ [no-force-push]
▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
Force pushes rewrite shared history. Use --force-with-lease.
CITE memory/never-force-push.md
This call was blocked by a tripwire rule. Do not retry it in another form; take another approach or ask the user.
The status line reads ▲ TRIPWIRE 7 ARMED, plus · 2 BAD when some rules were skipped.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
| None of its own. The one model call goes through Claude Code's own client. | No | Reads ~/.claude/tripwire.json and <project>/.claude/tripwire.json (and the HOME variable to find the first). The project file is loaded from any repo you open, without a prompt; it may deny, ask or note but never rewrite (see Limits). Writes ~/.claude/tripwire.json only when you press ARM or run /tripwire init (only if the file is missing). Keeps hit counts in its plugin store. | Only on /tripwire add: one completion with compileModel (default haiku). | No | Only on /tripwire add: your sentence, the rule schema and three fixed examples go to your configured Claude model. Nothing else is sent. |
note rules add text the model reads and its deny/ask rules can block or interrupt calls. Review a repo's .claude/tripwire.json like code; /tripwire list shows which rules came from the project.ask relies on the permission prompt. In a mode that answers every prompt for you (for example bypass permissions), the mode decides.checks-after-push cannot know when you last pushed, so it reminds on every gh pr checks.hooks/register.tsx 520 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hits, TripwireArmedRule, TripwireProposal, TripwireSprung } from '../types'
5import { COMPILE_SYSTEM, buildCompilePrompt, parseProposal, uniqueId } from './compile'
6import { argumentsOf, evaluate, mergeRules, parseRuleFile, rawRulesOf } from './rules'
7import type { ParsedRules, Rule, RuleSource } from './rules'
8import { ARMED_SPRITE, PICO8, TRAP_SPRITE, spriteRuns } from './sprite'
9import { STARTER_RULES } from './starter'
10import { askReason, clockText, hitsOf, noteText, recordHits, rewriteText, statusText, trapText } from './text'
11
12type Engine = EngineInterface
13
14const PANE = 'tripwire'
15const HITS_KEY = 'hits'
16
17const RULES = { plugin: 'tripwire', key: 'rules' } as const
18const rulesAtom = atom(RULES, [])
19const PROBLEMS = { plugin: 'tripwire', key: 'problems' } as const
20const problemsAtom = atom(PROBLEMS, [])
21const DISARMED = { plugin: 'tripwire', key: 'disarmed' } as const
22const disarmedAtom = atom(DISARMED, [])
23const HITS = { plugin: 'tripwire', key: 'hits' } as const
24const hitsAtom = atom(HITS, {})
25const SPRUNG = { plugin: 'tripwire', key: 'sprung' } as const
26const sprungAtom = atom(SPRUNG, null)
27const PROPOSAL = { plugin: 'tripwire', key: 'proposal' } as const
28const proposalAtom = atom(PROPOSAL, null)
29const NOTICE = { plugin: 'tripwire', key: 'notice' } as const
30const noticeAtom = atom(NOTICE, null)
31const LOADED = { plugin: 'tripwire', key: 'loaded' } as const
32const loadedAtom = atom(LOADED, false)
33
34const ACTION_COLOR: Record<Rule['action'], string> = {
35 deny: PICO8.r,
36 ask: PICO8.o,
37 rewrite: PICO8.u,
38 note: PICO8.y,
39}
40
41const USAGE = [
42 'usage: /tripwire open the TRAPS ARMED pane',
43 ' /tripwire list list the rules as text',
44 ' /tripwire reload re-read ~/.claude/tripwire.json and <project>/.claude/tripwire.json',
45 ' /tripwire add <sentence> propose a rule from plain English (you press ARM)',
46 ' /tripwire init write the starter pack to ~/.claude/tripwire.json if it is missing',
47].join('\n')
48
49const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
50
51const armedCount = (rules: readonly TripwireArmedRule[], disarmed: readonly string[]) =>
52 rules.filter(rule => !disarmed.includes(rule.id)).length
53
54const proposalText = (proposal: TripwireProposal) =>
55 'rule' in proposal
56 ? `PROPOSED RULE for "${proposal.sentence}":\n${JSON.stringify(proposal.rule, null, 2)}\nPress ARM in the /tripwire pane to append it to ~/.claude/tripwire.json, or DISCARD. Nothing is written until you do.`
57 : `COULD NOT COMPILE "${proposal.sentence}": ${proposal.reason}. Nothing was armed.`
58
59// ── files ────────────────────────────────────────────────────────────────
60
61const userFile = async ($: Engine): Promise<string | undefined> => {
62 const home = await $.env.get('HOME')
63 return home ? `${home}/.claude/tripwire.json` : undefined
64}
65
66const projectFile = async ($: Engine): Promise<string | undefined> => {
67 const root = await $.session.root().catch(() => undefined)
68 return root ? `${root}/.claude/tripwire.json` : undefined
69}
70
71const readRuleFile = async ($: Engine, path: string | undefined, source: RuleSource): Promise<ParsedRules> => {
72 if (path === undefined) return { rules: [], problems: [] }
73 try {
74 if (!(await $.fs.exists(path))) return { rules: [], problems: [] }
75 return parseRuleFile(await $.fs.read(path), source)
76 } catch (error) {
77 return { rules: [], problems: [`${source} file could not be read: ${errorText(error)}`] }
78 }
79}
80
81// ── session state ────────────────────────────────────────────────────────
82
83const title = async ($: Engine) =>
84 `TRAPS ARMED: ${armedCount(await read($, rulesAtom), await read($, disarmedAtom))}`
85
86const showStatus = async ($: Engine) => {
87 const rules = await read($, rulesAtom)
88 const bad = (await read($, problemsAtom)).length
89 $.ui.status(statusText(armedCount(rules, await read($, disarmedAtom)), bad))
90}
91
92/** Reads both files, keeps the good rules, and reports the bad ones in one toast. */
93const loadRules = async ($: Engine) => {
94 const merged = mergeRules([
95 await readRuleFile($, await userFile($), 'user'),
96 await readRuleFile($, await projectFile($), 'project'),
97 ])
98 await $.state.set(RULES, merged.rules)
99 await $.state.set(PROBLEMS, merged.problems)
100 await $.state.set(LOADED, true)
101 try {
102 const stored = hitsOf(await $.store.get(HITS_KEY))
103 if (stored !== undefined) await $.state.set(HITS, stored)
104 } catch {
105 // A store that cannot be read leaves the session's counts as they are.
106 }
107 const bad = merged.problems.length
108 if (bad > 0) $.ui.toast(`TRIPWIRE: ${bad} BAD RULE${bad === 1 ? '' : 'S'} SKIPPED · /tripwire`)
109 await showStatus($)
110}
111
112/**
113 * A /clear starts a new session with empty $.state and fires no session.start,
114 * so every hook that reads the rules loads them first if this session has not.
115 */
116const ensureLoaded = async ($: Engine) => {
117 if (!(await read($, loadedAtom))) await loadRules($)
118}
119
120/**
121 * Adds this call's hits to the counts in the store as they are now, not as
122 * this session last read them, so concurrent sessions add up instead of
123 * overwriting each other. The session's atom mirrors the merged map.
124 */
125const countHits = async ($: Engine, rules: readonly Rule[]) => {
126 try {
127 const now = await $.clock.now()
128 const ids = rules.map(rule => rule.id)
129 let base: Hits | undefined
130 try {
131 base = hitsOf(await $.store.get(HITS_KEY))
132 } catch {
133 base = undefined
134 }
135 const hits = recordHits(base ?? (await read($, hitsAtom)), ids, now)
136 await $.state.set(HITS, hits)
137 await $.store.set(HITS_KEY, hits)
138 } catch {
139 // Counting is best effort: a failed write never blocks enforcement.
140 }
141}
142
143const listText = async ($: Engine): Promise<string> => {
144 const rules = await read($, rulesAtom)
145 const disarmed = await read($, disarmedAtom)
146 const hits = await read($, hitsAtom)
147 const problems = await read($, problemsAtom)
148 const lines = [await title($)]
149 if (rules.length === 0) lines.push(' no rules yet: /tripwire init writes the starter pack')
150 for (const rule of rules) {
151 const hit = hits[rule.id]
152 const state = disarmed.includes(rule.id) ? 'DISARMED' : rule.action.toUpperCase()
153 lines.push(` ${rule.id} ${state} x${hit?.count ?? 0} ${clockText(hit?.lastHit)} (${rule.source})`)
154 }
155 if (problems.length > 0) {
156 lines.push('BAD RULES (skipped):')
157 for (const problem of problems) lines.push(` ${problem}`)
158 }
159 return lines.join('\n')
160}
161
162const openPane = async ($: Engine) => {
163 try {
164 await $.ui.open({ id: PANE, title: await title($) })
165 } catch {
166 // No surface places panes (a -p run): the command's text reply stands.
167 }
168}
169
170// ── actions ──────────────────────────────────────────────────────────────
171
172const toggleRule = async ($: Engine, id: string) => {
173 await update($, disarmedAtom, list => (list.includes(id) ? list.filter(one => one !== id) : [...list, id]))
174 await openPane($)
175 await showStatus($)
176}
177
178/** The human's ARM: append the proposed rule to the user file, then reload. */
179const armProposal = async ($: Engine) => {
180 const proposal = await read($, proposalAtom)
181 if (proposal === null || !('rule' in proposal)) return
182 const path = await userFile($)
183 if (path === undefined) {
184 await $.state.set(NOTICE, 'HOME is not set: nothing written.')
185 return
186 }
187 try {
188 let data: unknown = { rules: [] }
189 if (await $.fs.exists(path)) data = JSON.parse(await $.fs.read(path))
190 const existing = rawRulesOf(data)
191 if (existing === undefined) {
192 await $.state.set(NOTICE, `${path} is not a rule list: fix it first. Nothing written.`)
193 return
194 }
195 const taken = [
196 ...existing.map(raw => String((raw as { id?: unknown } | null)?.id ?? '')),
197 ...(await read($, rulesAtom)).map(rule => rule.id),
198 ]
199 const rule = { ...proposal.rule, id: uniqueId(proposal.rule.id, taken) }
200 const next = Array.isArray(data) ? [...data, rule] : { ...(data as object), rules: [...existing, rule] }
201 await $.fs.write(path, `${JSON.stringify(next, null, 2)}\n`)
202 await $.state.set(PROPOSAL, null)
203 await $.state.set(NOTICE, `ARMED ${rule.id} → ${path}`)
204 await loadRules($)
205 await openPane($)
206 } catch (error) {
207 await $.state.set(NOTICE, `Could not arm: ${errorText(error)}. Nothing written.`)
208 }
209}
210
211const discardProposal = async ($: Engine) => {
212 await $.state.set(PROPOSAL, null)
213 await $.state.set(NOTICE, 'DISCARDED. Nothing written.')
214}
215
216/** The model only proposes; this never writes. */
217const compile = async ($: Engine, sentence: string, compileModel: string): Promise<TripwireProposal> => {
218 try {
219 const reply = await $.model.complete({
220 model: compileModel,
221 system: COMPILE_SYSTEM,
222 prompt: buildCompilePrompt(sentence),
223 maxTokens: 800,
224 timeoutMs: 60000,
225 })
226 if (!reply.isAnswered) return { sentence, reason: `the model call failed (${reply.reason})` }
227 const parsed = parseProposal(reply.text)
228 return 'rule' in parsed ? { sentence, rule: parsed.rule } : { sentence, reason: parsed.reason }
229 } catch (error) {
230 return { sentence, reason: `the model call was refused (${errorText(error)})` }
231 }
232}
233
234const init = async ($: Engine): Promise<string> => {
235 const path = await userFile($)
236 if (path === undefined) return 'HOME is not set: nothing written.'
237 try {
238 if (await $.fs.exists(path)) {
239 return `${path} already exists: nothing written. The starter pack is in the plugin's examples/tripwire.json to copy from.`
240 }
241 await $.fs.write(path, `${JSON.stringify({ rules: STARTER_RULES }, null, 2)}\n`)
242 } catch (error) {
243 return `Could not write ${path}: ${errorText(error)}`
244 }
245 await loadRules($)
246 return `Wrote the starter pack to ${path}.\n${await listText($)}`
247}
248
249export const register: Register = (on, options) => {
250 const compileModel =
251 typeof options.compileModel === 'string' && options.compileModel !== '' ? options.compileModel : 'haiku'
252 const isBandOn = options.band !== false
253
254 // ── events ───────────────────────────────────────────────────────────────
255
256 on('session.start', async ($, e, next) => {
257 try {
258 await $.command.register({
259 name: 'tripwire',
260 description: 'Show the armed rules; add, reload or init them',
261 argumentHint: '[list | reload | add <sentence> | init]',
262 })
263 await loadRules($)
264 } catch (error) {
265 $.ui.toast(`TRIPWIRE: could not start (${errorText(error)})`)
266 }
267 return next(e)
268 })
269
270 // A /clear: the new session's state is empty; arm it again before any call.
271 on('classic.SessionStart', async ($, e, next) => {
272 if (e.source === 'clear') {
273 try {
274 await loadRules($)
275 } catch (error) {
276 $.ui.toast(`TRIPWIRE: could not reload after /clear (${errorText(error)})`)
277 }
278 }
279 return next(e)
280 })
281
282 on('command.run', { command: 'tripwire' }, async ($, e) => {
283 await ensureLoaded($)
284 const args = e.args.trim()
285 const [verb = '', ...rest] = args.split(/\s+/)
286 const sentence = rest.join(' ').trim()
287 switch (verb.toLowerCase()) {
288 case '':
289 await openPane($)
290 return { text: await listText($) }
291 case 'list':
292 return { text: await listText($) }
293 case 'reload':
294 await loadRules($)
295 await $.state.set(NOTICE, 'RELOADED')
296 return { text: await listText($) }
297 case 'init':
298 return { text: await init($) }
299 case 'add': {
300 if (sentence === '') return { text: 'Say the rule: /tripwire add <sentence>' }
301 const proposal = await compile($, sentence, compileModel)
302 await $.state.set(PROPOSAL, proposal)
303 await $.state.set(NOTICE, null)
304 await openPane($)
305 return { text: proposalText(proposal) }
306 }
307 default:
308 return { text: USAGE }
309 }
310 })
311
312 on('tool.call', async ($, e, next) => {
313 let outcome: ReturnType<typeof evaluate>
314 try {
315 await ensureLoaded($)
316 const rules = await read($, rulesAtom)
317 if (rules.length === 0) return next(e)
318 const disarmed = await read($, disarmedAtom)
319 outcome = evaluate(rules, disarmed, e.tool, argumentsOf(e as unknown as Record<string, unknown>))
320 } catch {
321 return next(e)
322 }
323 if (outcome.hits.length > 0) await countHits($, outcome.hits)
324 if (outcome.deny !== undefined) {
325 const rule = outcome.deny
326 try {
327 const sprung: TripwireSprung = { id: rule.id, message: rule.message, tool: e.tool, at: await $.clock.now() }
328 if (rule.cite) sprung.cite = rule.cite
329 await $.state.set(SPRUNG, sprung)
330 if (!isBandOn) $.ui.toast(`TRAP SPRUNG! ${rule.id}`)
331 } catch {
332 // The band is decoration; the deny below is the enforcement.
333 }
334 return { deny: trapText(rule) }
335 }
336 const ran = await next(outcome.isChanged ? ({ ...e, ...outcome.input } as typeof e) : e)
337 // A rewrite is never silent: the model learns what it actually ran.
338 const rewrites = outcome.rewrites.map(rewriteText)
339 if (rewrites.length > 0) $.ui.toast(`TRIPWIRE REWROTE ${e.tool.toUpperCase()}: ${outcome.rewrites.map(one => one.rule.id).join(', ')}`)
340 if (ran.deny !== undefined) return rewrites.length > 0 ? { deny: [ran.deny, ...rewrites].join('\n') } : ran
341 const context = [...rewrites, ...outcome.notes.map(noteText)]
342 if (context.length === 0) return ran
343 return { ...ran, context: [...(ran.context ?? []), ...context] } as typeof ran
344 })
345
346 on('tool.check', async ($, e, next) => {
347 const decided = await next(e)
348 if (decided.decision === 'deny') return decided
349 try {
350 const input = e.input
351 if (typeof input !== 'object' || input === null) return decided
352 await ensureLoaded($)
353 const rules = await read($, rulesAtom)
354 const disarmed = await read($, disarmedAtom)
355 const asks = evaluate(rules, disarmed, e.tool, input as Record<string, unknown>).asks
356 return asks.length > 0 ? { decision: 'ask', reason: askReason(asks) } : decided
357 } catch {
358 return decided
359 }
360 })
361
362 on('prompt.submit', async ($, e, next) => {
363 try {
364 if ((await read($, sprungAtom)) !== null) await $.state.set(SPRUNG, null)
365 } catch {
366 // Leaving the band up is harmless.
367 }
368 return next(e)
369 })
370
371 // ── drawing ──────────────────────────────────────────────────────────────
372
373 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
374 const sprung = await read($, sprungAtom)
375 if (!isBandOn || sprung === null || e.props.hasSurvey) return next(e)
376 const { Box, Text, Button } = $.ui.resolve(e)
377 const sprite = spriteRuns(TRAP_SPRITE)
378 return (
379 <Box flexDirection="row" gap={2}>
380 <Box flexDirection="column">
381 {sprite.map((row, y) => (
382 <Box key={`trap-row-${y}`} flexDirection="row">
383 {row.map(run => (
384 <Text color={run.color} backgroundColor={run.backgroundColor}>
385 {run.text}
386 </Text>
387 ))}
388 </Box>
389 ))}
390 </Box>
391 <Box flexDirection="column" flexShrink={1}>
392 <Box flexDirection="row" gap={1}>
393 <Text bold color={PICO8.w} backgroundColor={PICO8.r}>
394 {' TRAP SPRUNG! '}
395 </Text>
396 <Text bold color={PICO8.r}>
397 {sprung.id.toUpperCase()}
398 </Text>
399 <Text color={PICO8.l}>{`· ${sprung.tool.toUpperCase()} BLOCKED`}</Text>
400 </Box>
401 <Text color={PICO8.w} wrap="truncate-end">
402 {sprung.message}
403 </Text>
404 {sprung.cite ? (
405 <Text color={PICO8.v} wrap="truncate-end">{`CITE ${sprung.cite}`}</Text>
406 ) : (
407 <Text color={PICO8.d}>NO CITE</Text>
408 )}
409 <Box flexDirection="row">
410 <Button key="tripwire-ok" label="OK" role="dismiss" onPress={() => $.state.set(SPRUNG, null)} />
411 </Box>
412 </Box>
413 </Box>
414 )
415 })
416
417 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
418 const { Box, Text, Button } = $.ui.resolve(e)
419 const rules = await read($, rulesAtom)
420 const disarmed = await read($, disarmedAtom)
421 const hits = await read($, hitsAtom)
422 const problems = await read($, problemsAtom)
423 const proposal = await read($, proposalAtom)
424 const notice = await read($, noticeAtom)
425 const width = Math.max(24, e.props.bodyColumns)
426 const idWidth = Math.min(28, Math.max(8, ...rules.map(rule => rule.id.length)))
427 const armed = armedCount(rules, disarmed)
428 const icon = spriteRuns(ARMED_SPRITE)
429 const userCount = rules.filter(rule => rule.source === 'user').length
430
431 return (
432 <Box flexDirection="column" width={width}>
433 <Box flexDirection="row" gap={1}>
434 <Box flexDirection="column">
435 {icon.map((row, y) => (
436 <Box key={`icon-row-${y}`} flexDirection="row">
437 {row.map(run => (
438 <Text color={run.color} backgroundColor={run.backgroundColor}>
439 {run.text}
440 </Text>
441 ))}
442 </Box>
443 ))}
444 </Box>
445 <Box flexDirection="column">
446 <Text bold color={PICO8.r}>{`TRAPS ARMED: ${armed}`}</Text>
447 <Text color={PICO8.l}>
448 {`${userCount} USER · ${rules.length - userCount} PROJECT · ${rules.length - armed} DISARMED`}
449 </Text>
450 </Box>
451 </Box>
452 <Text color={PICO8.d}>{'─'.repeat(Math.min(width, 60))}</Text>
453 {rules.length === 0 && <Text color={PICO8.l}>NO TRAPS. /tripwire init WRITES THE STARTER PACK.</Text>}
454 {rules.map(rule => {
455 const isOff = disarmed.includes(rule.id)
456 const hit = hits[rule.id]
457 return (
458 <Box key={`rule-${rule.id}`} flexDirection="row" gap={1}>
459 <Text color={isOff ? PICO8.d : ACTION_COLOR[rule.action]}>{isOff ? '○' : '●'}</Text>
460 <Text color={isOff ? PICO8.d : PICO8.w} wrap="truncate-end">
461 {rule.id.toUpperCase().padEnd(idWidth).slice(0, idWidth)}
462 </Text>
463 <Text color={isOff ? PICO8.d : ACTION_COLOR[rule.action]}>
464 {(isOff ? 'OFF' : rule.action.toUpperCase()).padEnd(7)}
465 </Text>
466 <Text color={PICO8.y}>{`x${hit?.count ?? 0}`.padStart(4)}</Text>
467 <Text color={PICO8.l}>{clockText(hit?.lastHit)}</Text>
468 <Button
469 key={`disarm-${rule.id}`}
470 label={isOff ? 'REARM' : 'DISARM'}
471 onPress={() => toggleRule($, rule.id)}
472 />
473 </Box>
474 )
475 })}
476 {problems.length > 0 && (
477 <Box flexDirection="column" marginTop={1}>
478 <Text bold color={PICO8.o}>{`BAD RULES SKIPPED: ${problems.length}`}</Text>
479 {problems.map(problem => (
480 <Text color={PICO8.o} wrap="truncate-end">{`✕ ${problem}`}</Text>
481 ))}
482 </Box>
483 )}
484 {proposal !== null && 'rule' in proposal && (
485 <Box flexDirection="column" marginTop={1}>
486 <Text bold color={PICO8.y}>{'PROPOSED RULE · NOT ARMED'}</Text>
487 <Text color={PICO8.l} wrap="truncate-end">{`"${proposal.sentence}"`}</Text>
488 {JSON.stringify(proposal.rule, null, 2)
489 .split('\n')
490 .map(line => (
491 <Text color={PICO8.w} wrap="truncate-end">
492 {line}
493 </Text>
494 ))}
495 <Box flexDirection="row" gap={1}>
496 <Button key="tripwire-arm" label="ARM" variant="primary" onPress={() => armProposal($)} />
497 <Button key="tripwire-discard" label="DISCARD" onPress={() => discardProposal($)} />
498 </Box>
499 </Box>
500 )}
501 {proposal !== null && 'reason' in proposal && (
502 <Box flexDirection="column" marginTop={1}>
503 <Text bold color={PICO8.r}>{'COULD NOT COMPILE · NOTHING ARMED'}</Text>
504 <Text color={PICO8.l} wrap="truncate-end">{`"${proposal.sentence}"`}</Text>
505 <Text color={PICO8.o}>{proposal.reason}</Text>
506 <Box flexDirection="row">
507 <Button key="tripwire-discard" label="DISCARD" onPress={() => discardProposal($)} />
508 </Box>
509 </Box>
510 )}
511 {notice !== null && (
512 <Text color={PICO8.i} wrap="truncate-end">
513 {`▶ ${notice}`}
514 </Text>
515 )}
516 </Box>
517 )
518 })
519}
520hooks/compile.ts 105 lines1// `/tripwire add <sentence>`: the prompt that asks a model to propose one rule,
2// and the parser that turns its reply into a validated rule or a reason.
3import { validateRule } from './rules'
4import type { Rule } from './rules'
5
6export const COMPILE_SYSTEM =
7 'You compile one plain-English engineering rule into one JSON rule for a Claude Code tool-call guard. Reply with the JSON object only.'
8
9export const COMPILE_EXAMPLES: { sentence: string; rule: Rule }[] = [
10 {
11 sentence: 'Never force push; a lease is fine.',
12 rule: {
13 id: 'no-force-push',
14 tool: 'Bash',
15 match: String.raw`\bgit\s+push\b[^;&|\n]*\s(?:--force|-f)(?=\s|$)`,
16 field: 'command',
17 action: 'deny',
18 message: 'Force pushes rewrite shared history. Use --force-with-lease.',
19 },
20 },
21 {
22 sentence: 'Always ask me before anything touches api.example.com.',
23 rule: {
24 id: 'ask-before-example-api',
25 tool: '*',
26 match: String.raw`api\.example\.com`,
27 action: 'ask',
28 message: 'api.example.com is protected. Confirm first.',
29 },
30 },
31 {
32 sentence: 'Use pnpm, not npm, for installs.',
33 rule: {
34 id: 'pnpm-not-npm',
35 tool: 'Bash',
36 match: String.raw`\bnpm\s+(install|i)\b`,
37 field: 'command',
38 action: 'rewrite',
39 replace: 'pnpm install',
40 message: 'This repo installs with pnpm.',
41 },
42 },
43]
44
45const SCHEMA = `{
46 "id": "kebab-case-name",
47 "tool": "a tool name (Bash, Edit, Write, Read, WebFetch, ...), a glob such as mcp__*, or *",
48 "match": "a JavaScript regular expression tested against the field",
49 "field": "optional: which input field to test (Bash: command; Edit/Write/Read: file_path; WebFetch: url). Left out, the whole input as JSON",
50 "action": "deny | ask | rewrite | note",
51 "message": "one sentence the model reads when the rule fires",
52 "cite": "optional: where the rule was written down",
53 "replace": "only for action rewrite: the replacement text ($1 works)"
54}`
55
56/** The one-message prompt: the schema, three worked examples, then the sentence. */
57export const buildCompilePrompt = (sentence: string): string =>
58 [
59 'Rule schema:',
60 SCHEMA,
61 '',
62 'deny refuses the call, ask forces a permission prompt, rewrite changes the field before it runs (it needs "field" and "replace"), note lets the call run and tells the model the message.',
63 'Prefer deny for "never", ask for "check with me", rewrite for "use X instead of Y", note for reminders.',
64 '',
65 ...COMPILE_EXAMPLES.flatMap(example => [
66 `Sentence: ${example.sentence}`,
67 `Rule: ${JSON.stringify(example.rule)}`,
68 '',
69 ]),
70 `Sentence: ${sentence}`,
71 'Rule:',
72 ].join('\n')
73
74export type Proposal = { rule: Rule } | { reason: string }
75
76const jsonCandidate = (text: string): string | undefined => {
77 const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
78 const body = fenced?.[1] ?? text
79 const start = body.indexOf('{')
80 const end = body.lastIndexOf('}')
81 return start === -1 || end <= start ? undefined : body.slice(start, end + 1)
82}
83
84/** Parses the model's reply: a valid rule, or why it is not one. Never throws. */
85export const parseProposal = (text: string): Proposal => {
86 const candidate = jsonCandidate(text)
87 if (candidate === undefined) return { reason: 'the model did not reply with a JSON object' }
88 let data: unknown
89 try {
90 data = JSON.parse(candidate)
91 } catch (error) {
92 return { reason: `the reply is not valid JSON: ${error instanceof Error ? error.message : String(error)}` }
93 }
94 const checked = validateRule(data, 0)
95 return 'problem' in checked ? { reason: checked.problem } : { rule: checked.rule }
96}
97
98/** `id`, or `id-2`, `id-3`... when the id is taken. */
99export const uniqueId = (id: string, taken: readonly string[]): string => {
100 if (!taken.includes(id)) return id
101 let n = 2
102 while (taken.includes(`${id}-${n}`)) n += 1
103 return `${id}-${n}`
104}
105hooks/rules.ts 191 lines1// Pure rule logic: validation, file parsing, tool and field matching, and
2// evaluation of a call against the armed rules. No `$`, no state.
3
4import type { TripwireArmedRule, TripwireRule, TripwireRuleAction } from '../types'
5
6export const ACTIONS = ['deny', 'ask', 'rewrite', 'note'] as const
7export type RuleAction = TripwireRuleAction
8export type Rule = TripwireRule
9export type RuleSource = TripwireArmedRule['source']
10export type ArmedRule = TripwireArmedRule
11
12export type RuleCheck = { rule: Rule } | { problem: string }
13
14const isText = (value: unknown): value is string =>
15 typeof value === 'string' && value.trim() !== ''
16
17/** Checks one raw rule; a problem names the rule and what is wrong with it. */
18export const validateRule = (raw: unknown, index: number): RuleCheck => {
19 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
20 return { problem: `rule #${index + 1} is not an object` }
21 }
22 const record = raw as Record<string, unknown>
23 const label = isText(record.id) ? `"${record.id}"` : `#${index + 1}`
24 const fail = (why: string): RuleCheck => ({ problem: `rule ${label}: ${why}` })
25
26 if (!isText(record.id)) return fail('needs an "id"')
27 if (!isText(record.tool)) return fail('needs a "tool" (a name, a glob such as mcp__*, or *)')
28 if (!isText(record.match)) return fail('needs a "match" regex')
29 if (!isText(record.message)) return fail('needs a "message"')
30 if (!ACTIONS.includes(record.action as RuleAction)) {
31 return fail(`unknown action "${String(record.action)}" (deny, ask, rewrite or note)`)
32 }
33 if (record.field !== undefined && !isText(record.field)) return fail('"field" must be a field name')
34 if (record.cite !== undefined && typeof record.cite !== 'string') return fail('"cite" must be text')
35 try {
36 new RegExp(record.match)
37 } catch (error) {
38 return fail(`bad regex: ${error instanceof Error ? error.message : String(error)}`)
39 }
40 if (record.action === 'rewrite') {
41 if (typeof record.replace !== 'string') return fail('a rewrite needs "replace"')
42 if (record.field === undefined) return fail('a rewrite needs a "field" to rewrite')
43 }
44
45 const rule: Rule = {
46 id: record.id,
47 tool: record.tool,
48 match: record.match,
49 action: record.action as RuleAction,
50 message: record.message,
51 }
52 if (record.field !== undefined) rule.field = record.field as string
53 if (record.cite !== undefined) rule.cite = record.cite as string
54 if (record.action === 'rewrite') rule.replace = record.replace as string
55 // Re-order to the documented key order for display and storage.
56 const { id, tool, match, field, action, message, cite, replace } = rule
57 return { rule: JSON.parse(JSON.stringify({ id, tool, match, field, action, message, cite, replace })) }
58}
59
60/** The list of raw rules a file holds: a bare array or `{ "rules": [...] }`. */
61export const rawRulesOf = (data: unknown): unknown[] | undefined => {
62 if (Array.isArray(data)) return data
63 if (typeof data === 'object' && data !== null && Array.isArray((data as { rules?: unknown }).rules)) {
64 return (data as { rules: unknown[] }).rules
65 }
66 return undefined
67}
68
69export type ParsedRules = { rules: ArmedRule[]; problems: string[] }
70
71/** Parses one rule file's text; bad rules are skipped and reported, never thrown. */
72export const parseRuleFile = (text: string, source: RuleSource): ParsedRules => {
73 let data: unknown
74 try {
75 data = JSON.parse(text)
76 } catch (error) {
77 return {
78 rules: [],
79 problems: [`${source} file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`],
80 }
81 }
82 const raws = rawRulesOf(data)
83 if (raws === undefined) {
84 return { rules: [], problems: [`${source} file must be an array of rules or { "rules": [...] }`] }
85 }
86 const rules: ArmedRule[] = []
87 const problems: string[] = []
88 raws.forEach((raw, index) => {
89 const checked = validateRule(raw, index)
90 if ('problem' in checked) problems.push(`${source} ${checked.problem}`)
91 else if (source === 'project' && checked.rule.action === 'rewrite') {
92 // A cloned repo must not be able to change the commands you run.
93 problems.push(`project rule "${checked.rule.id}": rewrite rules load only from ~/.claude/tripwire.json`)
94 } else rules.push({ ...checked.rule, source })
95 })
96 return { rules, problems }
97}
98
99/** Joins the files in order (user, then project); a repeated id is skipped and reported. */
100export const mergeRules = (parts: readonly ParsedRules[]): ParsedRules => {
101 const seen = new Set<string>()
102 const rules: ArmedRule[] = []
103 const problems: string[] = []
104 for (const part of parts) {
105 problems.push(...part.problems)
106 for (const rule of part.rules) {
107 if (seen.has(rule.id)) {
108 problems.push(`${rule.source} rule "${rule.id}": id already used, skipped`)
109 continue
110 }
111 seen.add(rule.id)
112 rules.push(rule)
113 }
114 }
115 return { rules, problems }
116}
117
118const escapeRegex = (text: string) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
119
120/** `*` matches every tool; a pattern with `*` is a glob; anything else is exact. */
121export const toolMatches = (pattern: string, tool: string): boolean => {
122 if (pattern === '*') return true
123 if (!pattern.includes('*')) return pattern === tool
124 const source = pattern.split('*').map(escapeRegex).join('.*')
125 return new RegExp(`^${source}$`).test(tool)
126}
127
128/** The text a rule tests: the named field, or the whole input as JSON. */
129export const fieldText = (field: string | undefined, input: Record<string, unknown>): string | undefined => {
130 if (field === undefined) return JSON.stringify(input)
131 const value = input[field]
132 if (value === undefined) return undefined
133 return typeof value === 'string' ? value : JSON.stringify(value)
134}
135
136/** One rewrite that changed the input: the field's text before and after. */
137export type Rewrite = { rule: Rule; field: string; before: string; after: string }
138
139export type Outcome = {
140 deny?: Rule
141 asks: Rule[]
142 notes: Rule[]
143 rewrites: Rewrite[]
144 hits: Rule[]
145 input: Record<string, unknown>
146 isChanged: boolean
147}
148
149/**
150 * Runs the rules over one call in file order. A deny stops the walk; a
151 * rewrite changes the input the later rules see; asks and notes collect.
152 */
153export const evaluate = (
154 rules: readonly Rule[],
155 disarmed: readonly string[],
156 tool: string,
157 original: Record<string, unknown>,
158): Outcome => {
159 let input = original
160 const outcome: Outcome = { asks: [], notes: [], rewrites: [], hits: [], input, isChanged: false }
161 for (const rule of rules) {
162 if (disarmed.includes(rule.id) || !toolMatches(rule.tool, tool)) continue
163 const text = fieldText(rule.field, input)
164 if (text === undefined || !new RegExp(rule.match).test(text)) continue
165 outcome.hits.push(rule)
166 if (rule.action === 'deny') {
167 outcome.deny = rule
168 break
169 }
170 if (rule.action === 'ask') outcome.asks.push(rule)
171 if (rule.action === 'note') outcome.notes.push(rule)
172 if (rule.action === 'rewrite' && rule.field !== undefined && typeof input[rule.field] === 'string') {
173 const before = input[rule.field] as string
174 const after = before.replace(new RegExp(rule.match, 'g'), rule.replace ?? '')
175 if (after !== before) {
176 input = { ...input, [rule.field]: after }
177 outcome.rewrites.push({ rule, field: rule.field, before, after })
178 outcome.isChanged = true
179 }
180 }
181 }
182 outcome.input = input
183 return outcome
184}
185
186/** The tool's own arguments of a `tool.call` event: everything but the envelope. */
187export const argumentsOf = (event: Record<string, unknown>): Record<string, unknown> => {
188 const { tool: _tool, tool_use_id: _id, agentId: _agent, ...rest } = event
189 return rest
190}
191hooks/sprite.ts 73 lines1// PICO-8 palette and a tiny half-block sprite renderer: two pixel rows per
2// terminal row, the top pixel as the text color, the bottom as the background.
3
4export const PICO8 = {
5 k: '#000000', // black
6 n: '#1D2B53', // navy
7 p: '#7E2553', // plum
8 g: '#008751', // green
9 b: '#AB5236', // brown
10 d: '#5F574F', // dark grey
11 l: '#C2C3C7', // light grey
12 w: '#FFF1E8', // white
13 r: '#FF004D', // red (tripwire's signature)
14 o: '#FFA300', // orange
15 y: '#FFEC27', // yellow
16 i: '#00E436', // lime
17 u: '#29ADFF', // blue
18 v: '#83769C', // lavender
19 m: '#FF77A8', // pink
20 e: '#FFCCAA', // peach
21} as const
22
23export type Run = { text: string; color?: string; backgroundColor?: string }
24
25const colorOf = (pixel: string | undefined): string | undefined =>
26 pixel === undefined || pixel === '.' ? undefined : (PICO8 as Record<string, string>)[pixel]
27
28/** Turns a grid of palette keys ('.' transparent) into rows of merged runs. */
29export const spriteRuns = (grid: readonly string[]): Run[][] => {
30 const rows: Run[][] = []
31 for (let y = 0; y < grid.length; y += 2) {
32 const top = grid[y] ?? ''
33 const bottom = grid[y + 1] ?? ''
34 const width = Math.max(top.length, bottom.length)
35 const runs: Run[] = []
36 for (let x = 0; x < width; x += 1) {
37 const up = colorOf(top[x])
38 const down = colorOf(bottom[x])
39 const cell: Run =
40 up === undefined && down === undefined
41 ? { text: ' ' }
42 : up === undefined
43 ? { text: '▄', color: down }
44 : down === undefined
45 ? { text: '▀', color: up }
46 : { text: '▀', color: up, backgroundColor: down }
47 const last = runs[runs.length - 1]
48 if (last && last.text[0] === cell.text && last.color === cell.color && last.backgroundColor === cell.backgroundColor) {
49 last.text += cell.text
50 } else {
51 runs.push(cell)
52 }
53 }
54 rows.push(runs)
55 }
56 return rows
57}
58
59/** A bomb on a tripwire, fuse lit: 12 x 8 pixels, 4 terminal rows. */
60export const TRAP_SPRITE = [
61 '.......oy...',
62 '......d..y..',
63 '....rrrr....',
64 '...rrwrrrr..',
65 '..rrwrrrrpr.',
66 '..rrrrrrrpr.',
67 '...prrrrpp..',
68 'dddddddddddd',
69]
70
71/** A small armed-trap icon for the pane header: 6 x 4 pixels, 2 rows. */
72export const ARMED_SPRITE = ['..ry..', '.rrrr.', '.rwrp.', 'dddddd']
73hooks/starter.ts 71 lines1// The starter pack: generic rules most people want on day one. Mirrored in
2// examples/tripwire.json, which is what `/tripwire init` writes.
3import type { Rule } from './rules'
4
5export const STARTER_RULES: Rule[] = [
6 {
7 id: 'no-force-push',
8 tool: 'Bash',
9 match: String.raw`\bgit(?:\s+(?:-[Cc]\s+\S+|-{1,2}\w[\w-]*(?:=\S+)?))*\s+push\b[^;&|\n]*\s(?:(?:--force|-f)(?=\s|$)|\+[^\s:+])`,
10 field: 'command',
11 action: 'deny',
12 message: 'Force pushes rewrite shared history. Use --force-with-lease.',
13 cite: 'memory/never-force-push.md',
14 },
15 {
16 id: 'no-verify',
17 tool: 'Bash',
18 match: String.raw`\bgit(?:\s+(?:-[Cc]\s+\S+|-{1,2}\w[\w-]*(?:=\S+)?))*\s+(?:commit|push|merge|rebase|am|cherry-pick)\b[^;&|\n]*\s--no-verify\b`,
19 field: 'command',
20 action: 'deny',
21 message: 'Hooks are there for a reason. Fix what they catch instead of skipping them.',
22 },
23 {
24 id: 'no-admin-merge',
25 tool: 'Bash',
26 match: String.raw`\bgh\s+pr\s+merge\b[^;&|\n]*\s--admin\b`,
27 field: 'command',
28 action: 'deny',
29 message: 'An admin merge bypasses branch protection. Get the checks and reviews green.',
30 },
31 {
32 id: 'no-checkout-discard',
33 tool: 'Bash',
34 match: String.raw`\bgit\s+checkout\b[^;&|\n]*\s--\s+\S`,
35 field: 'command',
36 action: 'deny',
37 message: 'git checkout -- <file> throws away uncommitted work. Back the file up first (cp file file.bak), then restore.',
38 },
39 {
40 id: 'no-rm-root',
41 tool: 'Bash',
42 match: String.raw`\brm\s+(?:-\S+\s+)*["']?(?:/|~|\$HOME|\$\{HOME\})/?\*?["']?(?=\s|;|&|\||$)`,
43 field: 'command',
44 action: 'deny',
45 message: 'rm on / or the home directory. Name the exact path you mean.',
46 },
47 {
48 id: 'no-curl-pipe-sh',
49 tool: 'Bash',
50 match: String.raw`\b(?:curl|wget)\b[^|;&\n]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b`,
51 field: 'command',
52 action: 'deny',
53 message: 'Piping a download into a shell runs code nobody read. Download, read, then run.',
54 },
55 {
56 id: 'checks-after-push',
57 tool: 'Bash',
58 match: String.raw`\bgh\s+pr\s+checks\b`,
59 field: 'command',
60 action: 'note',
61 message: 'Right after a push, gh pr checks can still list the previous commit\'s checks (a false green). Confirm they belong to the pushed head SHA before reporting CI status.',
62 },
63 {
64 id: 'protected-host',
65 tool: '*',
66 match: String.raw`api\.example\.com`,
67 action: 'ask',
68 message: 'Template: api.example.com stands for a host you protect. Edit the match, or delete this rule.',
69 },
70]
71hooks/text.ts 56 lines1// The words tripwire says: the deny the model reads, notes, the ask reason,
2// the status line, and the hit bookkeeping kept in $.store.
3import type { Hits } from '../types'
4import type { Rewrite, Rule } from './rules'
5
6const citeOf = (rule: Rule) => (rule.cite ? ` (${rule.cite})` : '')
7
8/** The deny the model reads as the tool's error. */
9export const trapText = (rule: Rule): string =>
10 [
11 '▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜',
12 `▌ TRAP SPRUNG! ▐ [${rule.id}]`,
13 '▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟',
14 rule.message,
15 ...(rule.cite ? [`CITE ${rule.cite}`] : []),
16 'This call was blocked by a tripwire rule. Do not retry it in another form; take another approach or ask the user.',
17 ].join('\n')
18
19/** What a note rule attaches for the model after the tool's result. */
20export const noteText = (rule: Rule): string => `tripwire note [${rule.id}]: ${rule.message}${citeOf(rule)}`
21
22/** What the model reads after a rewrite rule changed its call. */
23export const rewriteText = ({ rule, field, before, after }: Rewrite): string =>
24 `tripwire rewrite [${rule.id}]: ${rule.message}${citeOf(rule)} (${field} was: ${before} → now: ${after})`
25
26/** The permission dialog's reason when ask rules matched. */
27export const askReason = (rules: readonly Rule[]): string =>
28 `TRIPWIRE ${rules.map(rule => `[${rule.id}] ${rule.message}${citeOf(rule)}`).join(' | ')}`
29
30/** Adds one hit per id, stamped `now` (never moving lastHit back); returns a new map. */
31export const recordHits = (hits: Hits, ids: readonly string[], now: number): Hits => {
32 const next: Hits = { ...hits }
33 for (const id of ids) {
34 const old = next[id]
35 next[id] = { count: (old?.count ?? 0) + 1, lastHit: Math.max(old?.lastHit ?? now, now) }
36 }
37 return next
38}
39
40/** A stored hits value as a map, or undefined when it is not one. */
41export const hitsOf = (value: unknown): Hits | undefined =>
42 value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Hits) : undefined
43
44/** The status line: under 40 columns. */
45export const statusText = (armed: number, bad: number): string =>
46 `▲ TRIPWIRE ${armed} ARMED${bad > 0 ? ` · ${bad} BAD` : ''}`
47
48const two = (n: number) => String(n).padStart(2, '0')
49
50/** A last-hit time as HH:MM, or a dash when the rule never fired. */
51export const clockText = (ms: number | undefined): string => {
52 if (ms === undefined) return '--:--'
53 const at = new Date(ms)
54 return `${two(at.getHours())}:${two(at.getMinutes())}`
55}
56types/index.d.ts 43 lines1// tripwire's $.state contract. Values here live for the session and survive a
2// hot reload; hit counts are mirrored to $.store so they outlive the session.
3
4export type TripwireRuleAction = 'deny' | 'ask' | 'rewrite' | 'note'
5
6export type TripwireRule = {
7 id: string
8 tool: string
9 match: string
10 field?: string
11 action: TripwireRuleAction
12 message: string
13 cite?: string
14 replace?: string
15}
16
17export type TripwireArmedRule = TripwireRule & { source: 'user' | 'project' }
18
19export type Hit = { count: number; lastHit: number }
20export type Hits = Record<string, Hit>
21
22export type TripwireSprung = { id: string; message: string; cite?: string; tool: string; at: number }
23
24export type TripwireProposal =
25 | { sentence: string; rule: TripwireRule }
26 | { sentence: string; reason: string }
27
28declare module 'claude-code' {
29 interface PluginState {
30 tripwire: {
31 rules: TripwireArmedRule[]
32 problems: string[]
33 disarmed: string[]
34 hits: Hits
35 sprung: TripwireSprung | null
36 proposal: TripwireProposal | null
37 notice: string | null
38 /** Whether this session has read the rule files; a /clear starts it false. */
39 loaded: boolean
40 }
41 }
42}
43