SLOPSHOPPER

toolgate

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…

newguardcommandtoaststatusprocess
★ 25v0.17.1MITupdated 2026-10-15RiskAverseTech/toolgate/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · toolgate
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /toolgate ⎿ toolgate: toolgate · allow 0 · ask 0 · deny 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ toolgate: toolgate · allow 0 · ask 0 · deny 0
README

toolgate — the Claude Code mod

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:

EventCLIWhat happens
tool.checktoolgate decideallow 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 postSettles the ledger entry (confirmed, failed, denied), so a later bash helper.sh is judged as what helper.sh does. No model call.
/toolgatetoolgate audit --statsThe 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.

Install

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:

SettingDefault
TypeSafe API keynoneSecure storage. Or an OpenRouter key. Or neither, and the key toolgate init saved in ~/.toolgate/env is used.
toolgate commandtoolgateA full path if Claude Code's PATH lacks your npm bin (which toolgate).
Modeenforceshadow 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.

Develop

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.

Source 1 files
hooks/register.ts 207 lines
1// 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