Tool-call firewall for Claude Code built on TypeSafe's Jev: seven risk questions, an action ledger that judges files at write time, floors on the axes that…

The toolgate firewall as a Claude Code mod: in-process, installed from a marketplace, keys in Claude Code's secure settings, hot-reloadable, and tested with claude plugin test.
The engine stays in the toolgate CLI. This mod is the wiring:
| Event | CLI | What happens |
|---|---|---|
tool.check | toolgate decide | allow runs the tool · ask goes to the mode's decider (the dialog, or the auto-mode classifier) · deny refuses it with the reason the model reads · no opinion leaves Claude Code's own verdict. A write is proposed to the ledger. |
tool.call (after) | toolgate post | Settles the ledger entry (confirmed, failed, denied), so a later bash helper.sh is judged as what helper.sh does. No model call. |
/toolgate | toolgate audit --stats | The audit summary and this session's verdict counts. |
Task context comes from the session's own transcript ($.session.messages()), so the context axes see the real prompts. The mod never sees tool output.
npm install -g @riskaverse/toolgate # the engine
claude plugin marketplace add RiskAverseTech/toolgate
claude plugin install toolgate@riskaverse
Claude Code asks for the plugin's settings when it enables the mod:
| Setting | Default | |
|---|---|---|
| TypeSafe API key | none | Secure storage. Or an OpenRouter key. Or neither, and the key toolgate init saved in ~/.toolgate/env is used. |
| toolgate command | toolgate | A full path if Claude Code's PATH lacks your npm bin (which toolgate). |
| Mode | enforce | shadow records every verdict in the audit log and changes nothing. |
Requires Claude Code v2.1.287 or later with mods enabled. If the CLI is missing the mod says so and steps aside: Claude Code's own rules, mode and classifier still apply, and nothing becomes more permissive than it was without the mod.
claude --plugin-dir ./plugin # load from a checkout; edits hot-reload
npm run test:mod # claude plugin validate + claude plugin test
Policy, thresholds, rules and the ledger are the CLI's: ~/.toolgate/toolgate.yaml, as for the hook. The settings.json hook (toolgate init) and the mod gate the same calls the same way; run one, not both.
hooks/register.ts 207 lines1// toolgate as a Claude Code mod.
2//
3// The engine stays in the toolgate CLI (`npm install -g @riskaverse/toolgate`); this module is the
4// glue between Claude Code's events and that CLI:
5//
6// tool.check → `toolgate decide` the verdict: allow runs the tool, ask goes to the mode's decider
7// (the dialog, or the auto-mode classifier), deny refuses it with
8// the reason the model reads. A write is proposed to the ledger.
9// tool.call → `toolgate post` after the tool ran: settles the ledger entry (confirmed, failed,
10// denied), so a later `bash helper.sh` is judged as what helper.sh
11// does. No model call.
12// /toolgate the audit log's summary and the session's counts.
13//
14// Task context comes from the session's own transcript ($.session.messages()), so the off_task,
15// violates_constraint, unresolved_choice and authorized axes see the real prompts, not a file path.
16// The module never sees tool output, so text the agent read cannot argue for its own approval.
17//
18// If the CLI is missing, the mod says so loudly and steps aside: Claude Code's own rules, mode and
19// classifier still apply; nothing becomes more permissive than it was without the mod.
20import type { Register } from 'claude-code'
21
22type Verdict = 'allow' | 'ask' | 'deny' | 'passthrough'
23
24type Decision = {
25 verdict: Verdict
26 reason?: string
27 source?: string
28 latencyMs?: number
29 probabilities?: Record<string, number>
30}
31
32const EARLIER_PROMPTS = 2
33const GOAL_MIN_CHARS = 40
34const DECIDE_TIMEOUT_MS = 20_000
35const POST_TIMEOUT_MS = 10_000
36
37// Session counters: these reset on a hot reload, which is fine for a status line.
38const counts = { allow: 0, ask: 0, deny: 0, passthrough: 0, errors: 0 }
39let active = false
40let inactiveReason = ''
41let lastLatencyMs: number | undefined
42
43function statusLine(): string {
44 if (!active) return `toolgate: off (${inactiveReason})`
45 const ms = lastLatencyMs === undefined ? '' : ` · ${lastLatencyMs} ms`
46 return `toolgate · allow ${counts.allow} · ask ${counts.ask} · deny ${counts.deny}${ms}`
47}
48
49// A compaction summary arrives as a user message but is the agent's, not the person's: it is not
50// the current instruction (0.16.0 window: 78 of 82 truncation asks were calls judged against one).
51// It can stand in as the session goal when nothing the person typed is long enough to.
52const COMPACTION_SUMMARY = /^(?:\s*<[\w-]+\/>\s*)*(?:This session is being continued from a previous conversation|The summarized conversation included)/
53const PASTED_CONTENT = /<pasted_content\b[^>]*>[\s\S]*?<\/pasted_content>/g
54
55function typedPart(text: string): string {
56 return text.replace(PASTED_CONTENT, '[pasted content omitted]').replace(/<artifact-content-authored-by-others\/>\s*/g, '').trim()
57}
58
59/** The user's prompts as toolgate wants them: current, the ones before (oldest first), the opening request. */
60function taskContextFrom(messages: ReadonlyArray<{ role: string; text: string }>) {
61 const userTurns = messages.filter((m) => m.role === 'user' && m.text.trim().length > 0).map((m) => m.text)
62 const summaries = userTurns.filter((t) => COMPACTION_SUMMARY.test(t))
63 const prompts = userTurns.filter((t) => !COMPACTION_SUMMARY.test(t)).map(typedPart).filter((t) => t.length > 0)
64 if (prompts.length === 0) {
65 // Only a summary so far (a resumed session before the person typed): it is the goal, not the task.
66 const summary = summaries[0]
67 return summary ? { session_goal: summary.slice(0, 1200) } : undefined
68 }
69 const current_task = prompts[prompts.length - 1]
70 const earlier_prompts = prompts.slice(Math.max(0, prompts.length - 1 - EARLIER_PROMPTS), prompts.length - 1)
71 const session_goal = prompts.find((p) => p.length >= GOAL_MIN_CHARS) ?? summaries[0]?.slice(0, 1200)
72 return { current_task, earlier_prompts, session_goal }
73}
74
75function parseDecision(stdout: string): Decision | undefined {
76 try {
77 const d = JSON.parse(stdout) as Decision
78 return d && typeof d.verdict === 'string' ? d : undefined
79 } catch {
80 return undefined
81 }
82}
83
84export const register: Register = (on, options) => {
85 let binary = typeof options.binary === 'string' && options.binary.length > 0 ? options.binary : 'toolgate'
86 const shadow = options.mode === 'shadow'
87 const keyEnv: Record<string, string> = {}
88 if (typeof options.typesafe_api_key === 'string' && options.typesafe_api_key.length > 0) keyEnv.TYPESAFE_API_KEY = options.typesafe_api_key
89 if (typeof options.openrouter_api_key === 'string' && options.openrouter_api_key.length > 0) keyEnv.OPENROUTER_API_KEY = options.openrouter_api_key
90
91 on('session.start', async ($, e, next) => {
92 await $.command.register({ name: 'toolgate', description: 'toolgate: audit summary and this session’s verdict counts' })
93 // A desktop-app Claude Code has no shell PATH, so a bare `toolgate` is ENOENT there even though
94 // the terminal finds it (0.17.1). Resolve once: the configured command, then the login shells'
95 // PATH, then the usual npm global locations. Whatever answers `version` is used from then on.
96 const candidates: string[][] = [[binary, 'version']]
97 if (!binary.includes('/')) {
98 for (const sh of ['/bin/zsh', '/bin/bash']) candidates.push([sh, '-lc', `command -v ${binary}`])
99 for (const dir of ['/opt/homebrew/bin', '/usr/local/bin']) candidates.push([`${dir}/${binary}`, 'version'])
100 }
101 let olderCli = false
102 for (const argv of candidates) {
103 try {
104 const ran = await $.process.run(argv, { timeoutMs: 10_000 })
105 if (ran.exitCode !== 0) {
106 if (/Unknown command/i.test(ran.stderr + ran.stdout)) olderCli = true
107 continue
108 }
109 if (argv[1] === '-lc') {
110 // the login shell printed a path: verify it answers `version`
111 const found = ran.stdout.trim().split('\n')[0] ?? ''
112 if (!found.startsWith('/')) continue
113 const check = await $.process.run([found, 'version'], { timeoutMs: 10_000 })
114 if (check.exitCode !== 0) {
115 if (/Unknown command/i.test(check.stderr + check.stdout)) olderCli = true
116 continue
117 }
118 binary = found
119 } else {
120 binary = argv[0]!
121 }
122 active = true
123 break
124 } catch {
125 // not found this way; try the next
126 }
127 }
128 if (!active) {
129 inactiveReason = olderCli
130 ? `${binary} is older than 0.16; run: npm i -g @riskaverse/toolgate@latest`
131 : `${binary} not found on this app's PATH; npm install -g @riskaverse/toolgate, or set the full path in /config (the terminal's \`which toolgate\`)`
132 }
133 if (!active) $.ui.toast(`toolgate is not gating: ${inactiveReason}`)
134 // The settings.json hook (toolgate init) and this mod gate the same calls: both installed means
135 // every call is decided twice. Say so once; the person picks one.
136 try {
137 const settings = (await $.settings.read()) as { hooks?: { PreToolUse?: Array<{ hooks?: Array<{ command?: string }> }> } }
138 const hooked = (settings.hooks?.PreToolUse ?? []).some((m) => (m.hooks ?? []).some((h) => typeof h.command === 'string' && h.command.includes('toolgate')))
139 if (hooked && active) $.ui.toast('toolgate: the settings.json hook is installed too; calls will be decided twice. Keep the mod or the hook, not both.')
140 } catch {
141 // settings unreadable: nothing to warn about
142 }
143 $.ui.status(statusLine())
144 return next(e)
145 })
146
147 // The verdict. Runs for every tool, before the permission mode settles an ask.
148 on('tool.check', async ($, e, next) => {
149 if (!active) return next(e)
150 let decision: Decision | undefined
151 try {
152 const [session_id, cwd, messages] = await Promise.all([$.session.id(), $.session.cwd(), $.session.messages()])
153 const payload = {
154 session_id,
155 cwd,
156 host: 'mod',
157 tool_use_id: e.tool_use_id,
158 tool_name: e.tool,
159 tool_input: e.input,
160 task_context: taskContextFrom(messages),
161 }
162 const ran = await $.process.run([binary, 'decide'], { stdin: JSON.stringify(payload), env: keyEnv, timeoutMs: DECIDE_TIMEOUT_MS })
163 decision = parseDecision(ran.stdout)
164 if (!decision) throw new Error(ran.stderr.trim() || `no decision on stdout (exit ${ran.exitCode})`)
165 } catch (err) {
166 // The CLI itself answers `ask` on its own failures; this branch is the CLI not answering at all
167 // (killed, timed out, missing). Same rule: never a silent allow.
168 counts.errors += 1
169 const why = err instanceof Error ? err.message : String(err)
170 decision = { verdict: 'ask', reason: `toolgate could not decide (${why}); confirm manually`, source: 'fail-mode' }
171 }
172 if (decision.latencyMs !== undefined) lastLatencyMs = decision.latencyMs
173 counts[decision.verdict] += 1
174 $.ui.status(statusLine())
175 if (shadow || decision.verdict === 'passthrough') return next(e)
176 const reason = `[toolgate] ${decision.reason ?? decision.verdict}`
177 return { decision: decision.verdict, reason }
178 })
179
180 // After the tool ran: settle the ledger entry. Best effort, never changes the result.
181 on('tool.call', async ($, e, next) => {
182 const ran = await next(e)
183 if (!active || e.tool_use_id === undefined) return ran
184 const hook_event_name = ran.deny !== undefined ? 'PermissionDenied' : ran.isError === true ? 'PostToolUseFailure' : 'PostToolUse'
185 try {
186 const session_id = await $.session.id()
187 const payload = { session_id, tool_use_id: e.tool_use_id, tool_name: e.tool, hook_event_name }
188 await $.process.run([binary, 'post'], { stdin: JSON.stringify(payload), timeoutMs: POST_TIMEOUT_MS })
189 } catch {
190 // silent by design: an unsettled write only ever makes a later execution stricter
191 }
192 return ran
193 })
194
195 on('command.run', { command: 'toolgate' }, async ($) => {
196 const header = `${statusLine()}${shadow ? ' · shadow mode (recording, not enforcing)' : ''}${counts.errors ? ` · ${counts.errors} not decided` : ''}`
197 if (!active) return { text: header }
198 try {
199 const ran = await $.process.run([binary, 'audit', '--stats'], { timeoutMs: 10_000 })
200 const body = (ran.stdout || ran.stderr).trim()
201 return { text: body ? `${header}\n\n${body}` : header }
202 } catch (err) {
203 return { text: `${header}\n\naudit unavailable: ${err instanceof Error ? err.message : String(err)}` }
204 }
205 })
206}
207