SLOPSHOPPER

mod-firewall

A firewall for Claude Code mods: watches what every other mod does (network, processes, secret files, env vars) and asks, blocks or logs

newpanecommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-firewall
│ ┃ Mod firewall ✕ › fix the failing auth test and add an audit log call │ ┃ mode: ask · 0 mods seen · 0 calls blocked · │ ┃ /config to change ⏺ Read(src/auth.ts) │ ┃ No other mods have done anything yet. ⎿ Read 6 lines │ ┃ Activity ⏺ Update(src/auth.ts) │ ┃ Nothing yet. ⎿ 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 │ │ › /firewall │ ⎿ mod-firewall: Mod firewall opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod firewall
mode: ask · 0 mods seen · 0 calls blocked · /config to change No other mods have done anything yet. Activity Nothing yet.
README

mod-firewall

A firewall for Claude Code mods. Mods run inside Claude Code with your permissions and aren't sandboxed. This one watches what every other mod does and asks you, blocks the call, or logs it.

/plugin install mod-firewall --marketplace bbarney7/claude-code-mods

What it watches

Every engine call another mod makes passes through the firewall first, labelled with the mod that made it:

Kind of callInfo (logged, allowed)Sensitive (ask, or block in strict)Critical (always asks; strict blocks)
Network (http.fetch)—any requesta request whose URL, headers or body contain a value from your .env files or a known token shape (Anthropic, OpenAI, GitHub, AWS, Slack, Google keys, private keys)
Processes (process.run, process.spawn)—any program it starts—
File readsordinary filessecrets files: .env*, SSH keys, *.pem/*.key, .aws/credentials, .npmrc, .netrc, gh hosts, .docker/config.json, .kube/config, ~/.claude.json, Codex auth—
File writesinside the project, temp foldersoutside the project, shell rc files, LaunchAgents, git hooks, authorized_keys—
Environment variablesordinary namesnames with KEY, TOKEN, SECRET, PASSWORD, AUTH, COOKIE, SESSION…—
Transcript (session.messages)logged——

Calls made by Claude Code itself, by its built-in plugins, and by plugins your administrator manages aren't policed. The firewall never gets in Claude Code's own way.

Modes (/config)

SettingValuesDefault
Modeask: sensitive calls open a dialog (Allow once / Allow for this session / Always trust this mod / Block). strict: sensitive calls are blocked unless the mod is trusted. audit: allow everything and log it.ask
Trusted modscomma-separated names; trusted mods skip the dialog for sensitive calls, but critical ones still askempty
Refuse risky mods at loadrefuse to load an untrusted mod whose source pairs the network with processes, files, secret env vars or the transcriptoff

Mods you trust from the dialog or the pane are remembered across sessions. In a headless run (claude -p) nobody is there to answer, so ask behaves like strict. It fails closed.

The pane

/firewall opens it. It lists every mod the firewall has seen, with a risk level and badges for what its source uses (network, processes, reads files, secret env, reads transcript, hooks tool calls, rewrites prompts, watches other mods), its counts of allowed and blocked calls, and Trust / Block buttons. Below that is a live activity feed. Blocked calls also show as a toast and a count in the status line.

Limits

  • The load-time check only sees mods that load after the firewall. Mods that loaded earlier are still guarded on every call, but their source scan isn't shown. To make the firewall load first, run it from a folder: add its path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Mods loaded that way come before installed ones.
  • It guards what mods do through Claude Code's engine. A mod that starts a program (once you've allowed it) has handed control to that program, and anything the program does is outside the firewall's view. That's why every process start is treated as sensitive.
  • Hooks are watched, not stopped. A mod that hooks tool calls can auto-approve them or rewrite them. The firewall flags that capability in the pane ("hooks tool calls") but doesn't interfere with the hooks themselves.
  • Secret detection is pattern-based. It catches .env values (8 characters or more), their URL-encoded forms, and common token shapes. It doesn't catch secrets that have been encrypted, split or base64-encoded first.

Development

claude --plugin-dir plugins/mod-firewall
claude plugin validate plugins/mod-firewall
claude plugin test plugins/mod-firewall
Source 2 files
hooks/register.tsx 442 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Origin, PluginRegisterUses, Register, RenderInput } from 'claude-code'
3
4import type { Activity, Category, ModRecord, Risk, Severity, Verdict } from '../types'
5
6const SELF = 'mod-firewall'
7const PANE = 'mod-firewall'
8const ACTIVITY_CAP = 100
9
10const mods = atom({ plugin: 'mod-firewall', key: 'mods' } as const, [])
11const activity = atom({ plugin: 'mod-firewall', key: 'activity' } as const, [])
12const blockedCount = atom({ plugin: 'mod-firewall', key: 'blockedCount' } as const, 0)
13
14// ---------- what counts as sensitive ----------
15
16export type Mode = 'ask' | 'strict' | 'audit'
17export type Config = { mode: Mode; trusted: string[]; refuseRiskyAtLoad: boolean }
18export const DEFAULT_CONFIG: Config = { mode: 'ask', trusted: [], refuseRiskyAtLoad: false }
19
20export function readConfig(options: Readonly<Record<string, unknown>>): Config {
21  const mode = options.mode as Mode
22  return {
23    mode: mode === 'ask' || mode === 'strict' || mode === 'audit' ? mode : DEFAULT_CONFIG.mode,
24    trusted: String(options.trusted ?? '')
25      .split(',')
26      .map(name => name.trim())
27      .filter(Boolean),
28    refuseRiskyAtLoad: options.refuseRiskyAtLoad === true,
29  }
30}
31
32const SECRET_PATHS: readonly RegExp[] = [
33  /(^|\/)\.env(\.[\w.-]+)?$/i,
34  /\.(pem|key|p12|pfx|keystore|jks)$/i,
35  /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
36  /(^|\/)\.ssh\//,
37  /(^|\/)\.aws\/credentials$/,
38  /(^|\/)\.(npmrc|netrc|pypirc|git-credentials)$/,
39  /(^|\/)\.config\/gh\/hosts\.ya?ml$/,
40  /(^|\/)\.docker\/config\.json$/,
41  /(^|\/)\.kube\/config$/,
42  /(^|\/)\.claude\.json$/,
43  /(^|\/)\.codex\/auth\.json$/,
44  /(^|\/)Library\/Keychains\//,
45]
46
47export function isSecretPath(path: string): boolean {
48  return SECRET_PATHS.some(pattern => pattern.test(path))
49}
50
51export function isSecretEnvName(name: string): boolean {
52  return /(KEY|TOKEN|SECRET|PASSW(OR)?D|PASS$|CREDENTIAL|AUTH|COOKIE|SESSION|PRIVATE)/i.test(name)
53}
54
55// Writes outside the project, or into places that run code later (shell rc files, launch agents, git hooks).
56export function isRiskyWrite(path: string, projectRoot: string, home: string): boolean {
57  if (/(^|\/)(\.zshrc|\.bashrc|\.bash_profile|\.profile|\.zprofile)$/.test(path)) return true
58  if (/Library\/LaunchAgents\/|\/\.git\/hooks\/|\/\.ssh\/authorized_keys$/.test(path)) return true
59  if (projectRoot && path.startsWith(`${projectRoot}/`)) return false
60  if (path.startsWith('/tmp/') || path.startsWith('/private/tmp/') || path.startsWith('/var/folders/')) return false
61  return path.startsWith('/') || (home !== '' && path.startsWith(home))
62}
63
64// Well-known token shapes, so a secret is caught even when it never sat in a .env file.
65const TOKEN_PATTERNS: readonly [string, RegExp][] = [
66  ['an Anthropic API key', /sk-ant-[A-Za-z0-9_-]{20,}/],
67  ['an OpenAI API key', /sk-(proj-)?[A-Za-z0-9_-]{32,}/],
68  ['a GitHub token', /\b(gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
69  ['an AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
70  ['a Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
71  ['a Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
72  ['a private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
73]
74
75// Values from .env files worth watching for: long enough not to match by accident.
76export function parseDotenv(text: string): Map<string, string> {
77  const values = new Map<string, string>()
78  for (const raw of text.split('\n')) {
79    const line = raw.trim()
80    if (!line || line.startsWith('#')) continue
81    const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
82    if (!match) continue
83    const value = (match[2] ?? '').replace(/^(['"])(.*)\1$/, '$2').trim()
84    if (value.length >= 8) values.set(match[1] ?? '', value)
85  }
86  return values
87}
88
89// What secrets, if any, a piece of outgoing data carries: .env values by name, then token shapes.
90export function findSecrets(text: string, known: ReadonlyMap<string, string>): string[] {
91  if (!text) return []
92  const found: string[] = []
93  for (const [name, value] of known) {
94    if (text.includes(value) || text.includes(encodeURIComponent(value))) found.push(`${name} from .env`)
95  }
96  for (const [label, pattern] of TOKEN_PATTERNS) {
97    if (pattern.test(text)) found.push(label)
98  }
99  return found
100}
101
102export type Check = { category: Category; severity: Severity; target: string; reason: string }
103
104export function checkFetch(url: string, init: { method?: string; headers?: Record<string, string>; body?: string; socketPath?: string } | undefined, known: ReadonlyMap<string, string>): Check {
105  const method = (init?.method ?? 'GET').toUpperCase()
106  let host = url
107  try {
108    host = new URL(url).host
109  } catch {}
110  const target = init?.socketPath ? `${method} ${url} (via socket ${init.socketPath})` : `${method} ${host}`
111  const secrets = findSecrets([url, init?.body ?? '', ...Object.values(init?.headers ?? {})].join('\n'), known)
112  if (secrets.length) return { category: 'network', severity: 'critical', target, reason: `the request contains ${secrets.join(', ')}` }
113  return { category: 'network', severity: 'sensitive', target, reason: `network request to ${init?.socketPath ? 'a local socket' : host}` }
114}
115
116export function checkRead(path: string): Check {
117  return isSecretPath(path)
118    ? { category: 'secret-file', severity: 'sensitive', target: path, reason: `read a secrets file (${path})` }
119    : { category: 'file-read', severity: 'info', target: path, reason: 'file read' }
120}
121
122export function checkWrite(path: string, projectRoot: string, home: string): Check {
123  return isRiskyWrite(path, projectRoot, home)
124    ? { category: 'file-write', severity: 'sensitive', target: path, reason: `write outside the project (${path})` }
125    : { category: 'file-write', severity: 'info', target: path, reason: 'file write in the project' }
126}
127
128export function checkEnv(name: string): Check {
129  return isSecretEnvName(name)
130    ? { category: 'secret-env', severity: 'sensitive', target: name, reason: `read the environment variable ${name}` }
131    : { category: 'env', severity: 'info', target: name, reason: 'environment variable read' }
132}
133
134export function checkProcess(argv: readonly string[]): Check {
135  const shown = argv.join(' ').slice(0, 160)
136  return { category: 'process', severity: 'sensitive', target: shown, reason: `run a program on your machine: ${shown}` }
137}
138
139// ---------- what a mod can do, from the engine's scan at load ----------
140
141export function badgesFor(uses: PluginRegisterUses): { badges: string[]; risk: Risk } {
142  const calls = new Set(uses.calls)
143  const events = uses.events
144  const has = (...names: string[]) => names.some(name => calls.has(name))
145  const badges: string[] = []
146  const network = has('http.fetch')
147  const process = has('process.run', 'process.spawn')
148  const files = has('fs.read', 'fs.list')
149  const writes = has('fs.write')
150  const secretEnv = (uses.env?.reads ?? []).filter(isSecretEnvName)
151  const transcript = has('session.messages', 'model.fork')
152  if (network) badges.push('network')
153  if (process) badges.push('processes')
154  if (files) badges.push('reads files')
155  if (writes) badges.push('writes files')
156  if (secretEnv.length) badges.push(`secret env: ${secretEnv.join(', ')}`)
157  else if (uses.env?.reads.length) badges.push('env vars')
158  if (transcript) badges.push('reads transcript')
159  if (events.some(e => e === 'tool.call' || e === 'tool.check' || e === '*' || e.startsWith('tool.'))) badges.push('hooks tool calls')
160  if (events.some(e => e === 'prompt.submit' || e === 'prompt.compose' || e === '*')) badges.push('rewrites prompts')
161  if (events.some(e => ['http.fetch', 'fs.read', 'process.run', 'process.spawn', 'plugin.register', '*'].includes(e))) badges.push('watches other mods')
162  const reach = process || files || writes || secretEnv.length > 0 || transcript
163  const risk: Risk = network && reach ? 'high' : network || process || writes || secretEnv.length ? 'medium' : 'low'
164  return { badges, risk }
165}
166
167// ---------- decisions ----------
168
169export type Decision = 'allow' | 'ask' | 'block' | 'flag'
170
171export function decide(mode: Mode, severity: Severity, isTrusted: boolean, isBlockedMod: boolean, isSessionAllowed: boolean): Decision {
172  if (severity === 'info') return 'allow'
173  if (isBlockedMod) return 'block'
174  if (mode === 'audit') return 'flag'
175  if (severity === 'critical') return mode === 'strict' ? 'block' : 'ask'
176  if (isTrusted || isSessionAllowed) return 'allow'
177  return mode === 'strict' ? 'block' : 'ask'
178}
179
180// Calls Claude Code itself makes, or its bundled and admin-managed plugins, are not ours to police.
181export function isPoliced(origin: Origin): boolean {
182  if (origin.plugin === SELF || origin.plugin === 'engine' || origin.plugin === 'client') return false
183  return origin.tier === 'user'
184}
185
186// ---------- module state (starts over on a reload) ----------
187
188let config: Config = DEFAULT_CONFIG
189let projectRoot = ''
190let home = ''
191let known = new Map<string, string>()
192let rememberedTrust: string[] = []
193const sessionAllowed = new Set<string>()
194const blockedMods = new Set<string>()
195const pendingAsks = new Map<string, Promise<boolean>>()
196let seq = 0
197
198const ALLOW_ONCE = 'Allow once'
199const ALLOW_SESSION = 'Allow for this session'
200const ALWAYS = 'Always trust this mod'
201const BLOCK = 'Block'
202
203async function loadSecrets($: EngineInterface) {
204  projectRoot = await $.session.root()
205  home = (await $.env.get('HOME')) ?? ''
206  const found = new Map<string, string>()
207  for (const name of ['.env', '.env.local', '.env.development', '.env.production']) {
208    try {
209      for (const [key, value] of parseDotenv(await $.fs.read(`${projectRoot}/${name}`))) found.set(key, value)
210    } catch {}
211  }
212  known = found
213  const stored = await $.store.get('trusted')
214  rememberedTrust = Array.isArray(stored) ? stored.filter((x): x is string => typeof x === 'string') : []
215}
216
217function isTrusted(mod: string): boolean {
218  return config.trusted.includes(mod) || rememberedTrust.includes(mod)
219}
220
221async function setTrust($: EngineInterface, mod: string, trusted: boolean) {
222  rememberedTrust = trusted ? [...new Set([...rememberedTrust, mod])] : rememberedTrust.filter(name => name !== mod)
223  if (trusted) blockedMods.delete(mod)
224  await $.store.set('trusted', rememberedTrust)
225  await touchMod($, mod, m => m)
226}
227
228async function setBlocked($: EngineInterface, mod: string, blocked: boolean) {
229  if (blocked) {
230    blockedMods.add(mod)
231    rememberedTrust = rememberedTrust.filter(name => name !== mod)
232    await $.store.set('trusted', rememberedTrust)
233  } else blockedMods.delete(mod)
234  await touchMod($, mod, m => m)
235}
236
237async function touchMod($: EngineInterface, name: string, fn: (mod: ModRecord) => ModRecord, provenance = '') {
238  await update($, mods, list => {
239    const at = list.findIndex(m => m.name === name)
240    const base: ModRecord = list[at] ?? { name, provenance, badges: [], risk: 'low', seenAt: 'runtime', allowed: 0, blocked: 0 }
241    const next = fn(base)
242    return at >= 0 ? list.map((m, i) => (i === at ? next : m)) : [...list, next]
243  })
244}
245
246async function record($: EngineInterface, mod: string, check: Check, verdict: Verdict) {
247  seq += 1
248  const entry: Activity = { id: `a${seq}`, at: await $.clock.now(), mod, category: check.category, target: check.target, verdict, reason: check.reason }
249  await update($, activity, list => [...list, entry].slice(-ACTIVITY_CAP))
250  if (verdict === 'info') return
251  await touchMod($, mod, m => ({
252    ...m,
253    allowed: m.allowed + (verdict === 'allowed' || verdict === 'asked-allowed' || verdict === 'flagged' ? 1 : 0),
254    blocked: m.blocked + (verdict === 'blocked' ? 1 : 0),
255  }))
256  if (verdict === 'blocked') {
257    const total = (await read($, blockedCount)) + 1
258    await update($, blockedCount, () => total)
259    $.ui.status(`🛡 mod firewall: ${total} blocked`)
260    $.ui.toast(`🛡 Blocked ${mod}: ${check.reason}`)
261  }
262  if (verdict === 'flagged' && check.severity === 'critical') $.ui.toast(`🛡 ${mod} sent a secret: ${check.reason}`)
263}
264
265// Asks once per mod and kind of call at a time: a burst of identical calls shares one dialog.
266async function ask($: EngineInterface, mod: string, check: Check): Promise<boolean> {
267  const key = `${mod}:${check.category}:${check.severity}`
268  const waiting = pendingAsks.get(key)
269  if (waiting) return waiting
270  const asking = (async () => {
271    const options = check.severity === 'critical' ? [ALLOW_ONCE, BLOCK] : [ALLOW_ONCE, ALLOW_SESSION, ALWAYS, BLOCK]
272    const warning = check.severity === 'critical' ? '⚠ ' : ''
273    try {
274      const answer = await $.ui.ask(`${warning}The mod "${mod}" wants to ${check.reason}. Allow it?`, { options, header: 'Mod firewall' })
275      if (answer === ALLOW_SESSION) sessionAllowed.add(`${mod}:${check.category}`)
276      if (answer === ALWAYS) await setTrust($, mod, true)
277      if (answer === BLOCK) await setBlocked($, mod, true)
278      return answer === ALLOW_ONCE || answer === ALLOW_SESSION || answer === ALWAYS
279    } catch {
280      // Dismissed, or nobody to ask (a -p run): the call does not go through.
281      return false
282    }
283  })()
284  pendingAsks.set(key, asking)
285  try {
286    return await asking
287  } finally {
288    pendingAsks.delete(key)
289  }
290}
291
292// The one place every guarded call is judged. Returns a deny reason, or undefined to let it through.
293async function judge($: EngineInterface, origin: Origin, check: Check): Promise<string | undefined> {
294  if (!isPoliced(origin)) return undefined
295  const mod = origin.plugin
296  const decision = decide(config.mode, check.severity, isTrusted(mod), blockedMods.has(mod), sessionAllowed.has(`${mod}:${check.category}`))
297  if (decision === 'allow') {
298    await record($, mod, check, check.severity === 'info' ? 'info' : 'allowed')
299    return undefined
300  }
301  if (decision === 'flag') {
302    await record($, mod, check, 'flagged')
303    return undefined
304  }
305  if (decision === 'ask' && (await ask($, mod, check))) {
306    await record($, mod, check, 'asked-allowed')
307    return undefined
308  }
309  await record($, mod, check, 'blocked')
310  return `mod firewall blocked ${mod}: ${check.reason}`
311}
312
313// ---------- drawing ----------
314
315const VERDICT_MARK: Record<Verdict, string> = { allowed: '✓', 'asked-allowed': '✓', blocked: '✕', flagged: '!', info: '·' }
316const VERDICT_COLOR: Record<Verdict, string> = { allowed: 'green', 'asked-allowed': 'green', blocked: 'red', flagged: 'yellow', info: 'gray' }
317const RISK_COLOR: Record<Risk, string> = { low: 'green', medium: 'yellow', high: 'red' }
318
319async function renderPane($: EngineInterface, e: RenderInput<'Pane'>) {
320  const { Box, Text, Button } = $.ui.resolve(e)
321  const list = await read($, mods)
322  const feed = await read($, activity)
323  const blocked = await read($, blockedCount)
324  const room = Math.max(5, (e.viewport?.rows ?? 30) - 8 - list.length * 3)
325
326  return (
327    <Box flexDirection="column">
328      <Text dimColor>{`mode: ${config.mode} · ${list.length} mods seen · ${blocked} calls blocked · /config to change`}</Text>
329      {list.length === 0 && <Text dimColor>No other mods have done anything yet.</Text>}
330      {list.map(mod => {
331        const trusted = isTrusted(mod.name)
332        const isBlocked = blockedMods.has(mod.name)
333        const state = mod.refused ? `refused at load: ${mod.refused}` : isBlocked ? 'blocked' : trusted ? 'trusted' : 'watched'
334        return (
335          <Box key={mod.name} flexDirection="column" marginTop={1}>
336            <Text>
337              <Text bold color={RISK_COLOR[mod.risk]}>{`${mod.name}`}</Text>
338              <Text dimColor>{`  ${mod.risk} risk · ${state} · ${mod.allowed} allowed, ${mod.blocked} blocked`}</Text>
339            </Text>
340            {mod.badges.length > 0 && <Text dimColor>{`  ${mod.badges.join(' · ')}`}</Text>}
341            {!mod.refused && (
342              <Box>
343                {!trusted && <Button key={`trust-${mod.name}`} label="Trust" onPress={() => setTrust($, mod.name, true)} />}
344                {trusted && <Button key={`untrust-${mod.name}`} label="Untrust" onPress={() => setTrust($, mod.name, false)} />}
345                {!isBlocked && <Button key={`block-${mod.name}`} label="Block" onPress={() => setBlocked($, mod.name, true)} />}
346                {isBlocked && <Button key={`unblock-${mod.name}`} label="Unblock" onPress={() => setBlocked($, mod.name, false)} />}
347              </Box>
348            )}
349          </Box>
350        )
351      })}
352      <Text bold>{'\nActivity'}</Text>
353      {feed.length === 0 && <Text dimColor>Nothing yet.</Text>}
354      {feed.slice(-room).map(entry => (
355        <Text key={entry.id} color={VERDICT_COLOR[entry.verdict]} dimColor={entry.verdict === 'info'}>
356          {`${VERDICT_MARK[entry.verdict]} ${entry.mod}  ${entry.category}  ${entry.target.slice(0, 90)}${entry.verdict === 'blocked' ? '  (blocked)' : ''}`}
357        </Text>
358      ))}
359      {feed.length > 0 && <Button key="clear" label="Clear activity" onPress={() => update($, activity, () => [])} />}
360    </Box>
361  )
362}
363
364// ---------- registration ----------
365
366// Each command is also declared in commands/, so a host lists it as `/mod-firewall:<name>` before
367// any session exists (the desktop app's empty chat); the hooks answer both spellings.
368export const register: Register = (on, options) => {
369  config = readConfig(options)
370
371  on('session.start', async ($, e, next) => {
372    await loadSecrets($)
373    await $.command.register({ name: 'firewall', description: 'Open the mod firewall: what every mod is doing, and trust or block them' }).catch(
374      error => $.ui.toast(`mod-firewall: ${String(error)}`),
375    )
376    return next(e)
377  })
378
379  on('command.run', { command: ['firewall', 'mod-firewall:firewall'] }, async $ => {
380    await $.ui.open({ id: PANE, title: 'Mod firewall' })
381    return { text: 'Mod firewall opened.' }
382  })
383
384  // At load: record what each later mod uses, and refuse risky untrusted ones when asked to.
385  on('plugin.register', async ($, e, next) => {
386    if (e.name === SELF || e.tier !== 'user') return next(e)
387    const { badges, risk } = badgesFor(e.uses)
388    $.ui.log(`mod-firewall: saw ${e.name} (${e.provenance}) at load: ${risk} risk${badges.length ? `, ${badges.join(', ')}` : ''}`, { to: 'debug' })
389    const refuse = config.refuseRiskyAtLoad && risk === 'high' && !isTrusted(e.name) ? `uses ${badges.join(', ')}` : undefined
390    await touchMod($, e.name, m => ({ ...m, provenance: e.provenance, badges, risk, seenAt: 'load', refused: refuse }), e.provenance)
391    if (refuse) {
392      $.ui.toast(`🛡 Refused to load ${e.name}: ${refuse}`)
393      return { refuse: `mod firewall: ${refuse}` }
394    }
395    return next(e)
396  }).catch(($, e, next) => (next.called ? next(e) : undefined))
397
398  on('http.fetch', async ($, e, next) => {
399    const denied = await judge($, next.origin, checkFetch(e.url, e.init, known))
400    return denied ? ({ deny: denied } as never) : next(e)
401  }).catch(($, e, next) => (next.called ? next(e) : ({ deny: 'mod firewall could not judge this request' } as never)))
402
403  on('fs.read', async ($, e, next) => {
404    const denied = await judge($, next.origin, checkRead(e.path))
405    return denied ? ({ deny: denied } as never) : next(e)
406  }).catch(($, e, next) => (next.called ? next(e) : ({ deny: 'mod firewall could not judge this read' } as never)))
407
408  on('fs.write', async ($, e, next) => {
409    const denied = await judge($, next.origin, checkWrite(e.path, projectRoot, home))
410    return denied ? ({ deny: denied } as never) : next(e)
411  }).catch(($, e, next) => (next.called ? next(e) : ({ deny: 'mod firewall could not judge this write' } as never)))
412
413  on('env.get', async ($, e, next) => {
414    const denied = await judge($, next.origin, checkEnv(e.name))
415    return denied ? ({ deny: denied } as never) : next(e)
416  }).catch(($, e, next) => (next.called ? next(e) : ({ deny: 'mod firewall could not judge this read' } as never)))
417
418  on('process.run', async ($, e, next) => {
419    const denied = await judge($, next.origin, checkProcess(e.argv))
420    return denied ? ({ deny: denied } as never) : next(e)
421  }).catch(($, e, next) => (next.called ? next(e) : ({ deny: 'mod firewall could not judge this command' } as never)))
422
423  on('process.spawn', async function* ($, e, next) {
424    const denied = await judge($, next.origin, checkProcess(e.argv))
425    if (denied) return { deny: denied } as never
426    return yield* next(e)
427  }).catch(async function* ($, e, next) {
428    if (next.called) return yield* next(e)
429    return { deny: 'mod firewall could not judge this command' } as never
430  })
431
432  // Transcript access is logged, not gated: a mod reading the conversation is worth knowing about.
433  on('session.messages', async ($, e, next) => {
434    if (isPoliced(next.origin)) {
435      await record($, next.origin.plugin, { category: 'transcript', severity: 'info', target: 'session.messages', reason: 'read the conversation' }, 'info')
436    }
437    return next(e)
438  }).catch(($, e, next) => next(e))
439
440  on('ui.render', { component: 'Pane', requestId: 'mod-firewall' }, ($, e) => renderPane($, e))
441}
442
types/index.d.ts 32 lines
1export type Category = 'network' | 'process' | 'secret-file' | 'file-read' | 'file-write' | 'secret-env' | 'env' | 'transcript'
2export type Severity = 'info' | 'sensitive' | 'critical'
3export type Verdict = 'allowed' | 'asked-allowed' | 'blocked' | 'flagged' | 'info'
4export type Risk = 'low' | 'medium' | 'high'
5
6export type ModRecord = {
7  name: string
8  provenance: string
9  badges: string[]
10  risk: Risk
11  seenAt: 'load' | 'runtime'
12  allowed: number
13  blocked: number
14  refused?: string
15}
16
17export type Activity = {
18  id: string
19  at: number
20  mod: string
21  category: Category
22  target: string
23  verdict: Verdict
24  reason?: string
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'mod-firewall': { mods: ModRecord[]; activity: Activity[]; blockedCount: number }
30  }
31}
32