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

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
Every engine call another mod makes passes through the firewall first, labelled with the mod that made it:
| Kind of call | Info (logged, allowed) | Sensitive (ask, or block in strict) | Critical (always asks; strict blocks) |
|---|---|---|---|
Network (http.fetch) | — | any request | a 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 reads | ordinary files | secrets files: .env*, SSH keys, *.pem/*.key, .aws/credentials, .npmrc, .netrc, gh hosts, .docker/config.json, .kube/config, ~/.claude.json, Codex auth | — |
| File writes | inside the project, temp folders | outside the project, shell rc files, LaunchAgents, git hooks, authorized_keys | — |
| Environment variables | ordinary names | names 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.
/config)| Setting | Values | Default |
|---|---|---|
| Mode | ask: 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 mods | comma-separated names; trusted mods skip the dialog for sensitive calls, but critical ones still ask | empty |
| Refuse risky mods at load | refuse to load an untrusted mod whose source pairs the network with processes, files, secret env vars or the transcript | off |
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.
/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.
CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Mods loaded that way come before installed ones..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.claude --plugin-dir plugins/mod-firewall
claude plugin validate plugins/mod-firewall
claude plugin test plugins/mod-firewallhooks/register.tsx 442 lines1import { 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}
442types/index.d.ts 32 lines1export 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