A firewall for other mods: shows what each mod can reach as it loads, and audits, holds or refuses it by your policy

A firewall for Claude Code mods: see what each mod's code can reach as it loads, and audit, hold or refuse it by your own policy.
A mod is a plugin that runs code inside Claude Code with your permissions. It can read every prompt, approve or rewrite tool calls, change the system prompt, start processes and reach the network. Nothing on screen tells you which of those a mod you just installed does.
modwall hooks the plugin.register event, which fires each time another mod's hooks module is about to load. That event carries what Claude Code's own scanner read from the mod's source: the event names it hooks, the $ calls it makes, the environment variables it names and the $.state values it refers to. The scan is exact for those four lists: the type declarations for PluginRegisterUses say "a module that spells on, $, $.env or a $.state reference other than literally does not load". Building a call name at run time is not a way around it; such a module never loads.
modwall turns those lists into reach labels and a risk level. The classifier is an allowlist: only a small known-safe set is low risk, and anything else, including anything modwall does not recognise, is high.
Low (known safe, nothing else used):
session.start, session.end, turn.startui.toast, ui.status, ui.log, ui.notice, ui.resolve, ui.invalidate, ui.blit, ui.open, ui.close, ui.panes, ui.scroll, ui.focus, every clock.*, store.get/set/delete/keys (a mod's store is its own), state.get/set on the mod's own values, command.register, and the read-only session facts session.cwd, root, model, turns, id, surface, surfaces and version/modwall scan only: a command.run hook the validator reports as answering the mod's own command, and a ui.render hook matched to the AbovePrompt band or a PaneMedium: reads environment variables, and none of their names looks like a secret.
High: everything else. The labels say why:
| Label | Examples |
|---|---|
prompts | hooks prompt.submit, prompt.fill, prompt.edit, prompt.attachment, session.append, session.send, session.receive, session.compact, turn.step, turn.complete, model.complete; calls session.messages, session.append, model.*, prompt.* |
tool-gate | hooks tool.call, tool.check, tool.describe, classic.PreToolUse, classic.PermissionRequest (can approve, refuse or rewrite tool calls) |
tool-calls | calls tool.*, mcp.* |
system-prompt | hooks prompt.section, prompt.compose, prompt.context, skill.prompt |
network | calls http.* |
processes | calls process.* |
secret-names | reads an environment variable whose name looks like a key, token, password or credential (modwall sees the name, not the value) |
env-write | calls env.set |
file-read, file-write | calls fs.read, fs.list, fs.stat, fs.exists, fs.ancestors; fs.write |
other-mods | hooks plugin.register, engine.create, state.get, state.set; refers to another mod's $.state values |
intercepts-api | hooks a $ call by name (store.get, store.set, http.fetch, process.run, fs.write, ui.toast ...), which lets it see, rewrite or refuse that call for every other mod |
agents, config, commands, screen | agent.spawn, config.set, a command.run hook, ui.render / ui.input / ui.press, ui.copy, ui.selection, ui.ask |
telemetry | hooks telemetry.* |
wildcard | hooks *, prefix.* or a !negation, listed with the groups it covers |
unknown | any event or call not in the lists above |
At load, plugin.register gives the event names without their matchers (command.run, not command.run{command=foo}), so modwall cannot tell a mod's own command or status band from a hook on every command or every transcript row. Both count as high at load. /modwall scan reads the validator's report, which does show matchers, and can rate those mods lower.
Three policy modes:
audit (default): every mod loads, except those on your blocklist. modwall records each one, logs it and toasts each high-risk mod the first time it sees it at that declared version.ask: high-risk mods that are not on your allowlist are refused at load and shown as held. Approve one with /modwall allow <name> and run /reload-plugins. Claude Code offers no dialog while mods load, so "ask" means hold first, approve after.allowlist: every user mod not on the allowlist is refused.Allowlist entries are a provenance plus a version. Both are the mod's own word: name and version come from its own plugin.json, and provenance is that name plus where it was loaded from (name@marketplace when installed, name@inline for any --plugin-dir folder). So the version pin only means "the version string the mod declares". A mod can ship new code under the same version string, and any folder whose plugin.json says "name": "foo" loaded with --plugin-dir is foo@inline, matching an allowlist entry made for a different foo folder.
A blocklist applies in every mode. The allowlist, blocklist and a decision log (last 500 entries) are kept in modwall's $.store, so they survive across sessions.
modwall can only judge mods that load after it. What we observed on Claude Code 2.1.293 (observed behaviour, not documented):
--plugin-dir mods loaded in the order of the flags.--plugin-dir modwall judged an installed mod, so --plugin-dir mods loaded ahead of installed ones there.Two ways to put modwall ahead of other mods:
prependPlugins in user settings. In ~/.claude/settings.json: { "prependPlugins": ["modwall@modwall"] }
In our isolated test (no managed settings, not signed in, modwall installed from a local directory marketplace) this loaded modwall in the prepend tier, and it judged every user mod whatever the install order. The docs say Claude Code reads prependPlugins from user settings only on a machine with no managed settings and when you are not signed in with a Team or Enterprise plan. We have not confirmed it for a signed-in account, or for a copy installed from GitHub.
On a managed machine, prependPlugins in managed settings skips any plugin Claude Code copies from a GitHub, git, URL or npm source; those count as a user's mod. An administrator who wants modwall there has to vendor it into a directory marketplace on disk, listed with a relative source, as the admin docs describe.
Pick the mode in /plugin configure modwall@modwall, in the config menu, or in settings:
{ "pluginConfigs": { "modwall@modwall": { "options": { "mode": "allowlist" } } } }
For a --plugin-dir copy the key is modwall@inline.
From the marketplace, at the prompt of a terminal session:
/plugin marketplace add RadTech-Solutions/modwall
/plugin install modwall@modwall
or in one line:
/plugin install modwall --marketplace RadTech-Solutions/modwall
Answer y to add the marketplace, then pick the user scope.
From a clone, for one session:
git clone https://github.com/RadTech-Solutions/modwall
claude --plugin-dir ./modwall --plugin-dir ./some-other-mod
List modwall's --plugin-dir before the mods it should judge.
/modwall mods seen this session: reach, risk, decision
/modwall allow <name> [version|*] allow a mod (a name seen this session, or name@marketplace)
/modwall block <name> refuse a mod at every load
/modwall unblock <name>
/modwall lists show the allowlist and blocklist
/modwall log the persistent decision log
/modwall scan validate every installed plugin and report its footprint
/modwall allow <name> for a mod seen this session pins its current declared version. /modwall allow name@marketplace with no version allows any version, and the reply says so. If two mods seen this session share a name, modwall asks for the name@marketplace form.
Example /modwall output:
modwall (audit mode), 2 mod(s) seen this session:
MOD VERSION RISK DECISION REACH
spy 6.6.6 high flagged prompts, tool-gate, network, processes, secret-names
dummy 1.2.3 high flagged tool-gate, file-read, file-write
/modwall scan does not depend on load order. It runs claude plugin list --json, then claude plugin validate --json <folder> for each installed plugin whose folder is an absolute path, and prints each one's reach and risk, its MCP servers, and whether it carries command hooks. Use it to review what is installed, including mods that loaded before modwall.
Allowing or blocking takes effect at the next load. Run /reload-plugins to apply it now.
It can:
ask and allowlist mode, if modwall's check throws or times out, the user mod it was checking is refused. In audit mode the blocklist is still checked when the main check fails, as far as the store can be read. A stored list that is present but damaged (not a list of the right shape) makes ask and allowlist refuse every user mod, and is reported in /modwall and /modwall lists.It cannot:
/modwall scan still reports them.prepend and append tiers and those built into Claude Code are recorded as not-judged and always pass.$.process.run has handed everything to that program. Treat processes and network as "can do anything".store.get, store.set or process.run by name sits in the same chain as modwall's own calls and can feed it a different allowlist, hide its log writes, or rewrite what /modwall scan reads. modwall flags such mods as intercepts-api, but once one is loaded, modwall's later decisions and reports cannot be trusted. Use allowlist mode, or ask mode, to keep them out.hooks/hooks.json, MCP servers, skills and agents are not hooks modules and never fire plugin.register. scan lists them; it cannot refuse them.--safe-mode or a crashed hooks worker. In both cases installed mods do not run, modwall included (and so do the mods it would judge).modwall keeps its own reach small. claude plugin validate . reports exactly this:
plugin.register, session.start, and command.run matched to its own /modwall command.$.command.register (to add /modwall), $.store for its lists and log, $.state for the session's table, $.clock.now for timestamps, $.ui.toast for alerts, and $.process.run only inside /modwall scan.$.http (no network), no $.env reads, no hooks on other $ calls./modwall scan runs fixed argv only: claude plugin list --json and claude plugin validate --json <folder>, and skips any folder that is not an absolute path.By its own rules modwall rates itself high risk (other-mods, processes), because a mod that can refuse other mods is a powerful thing to install. Read hooks/register.tsx and hooks/classify.ts before you trust it.
claude plugin validate .
claude plugin test .
claude plugin test runs the tests in hooks/*.test.tsx: the allowlist classifier (safe set, every high group, hooks on $ call names, wildcards, other mods' state), the policy decision in each mode, damaged stored lists, name resolution, parsing real claude plugin validate --json output, refusal in allowlist and ask mode, the blocklist, the /modwall table, log and list commands, scan against stubbed claude output, judging from the prepend tier, and fail closed when the check throws.
Type check with a tsconfig.json kept outside the repo, using the declaration file Claude Code writes beside a loaded mod (.claude-plugin/types/claude-code/index.d.ts) or the one in the plugin-authoring skill:
{
"compilerOptions": {
"target": "es2023", "lib": ["es2023"], "module": "esnext", "moduleResolution": "bundler",
"strict": true, "noUncheckedIndexedAccess": true, "noEmit": true, "skipLibCheck": true,
"jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment", "types": []
},
"files": ["/path/to/claude-code.d.ts"],
"include": ["/path/to/modwall/hooks/**/*", "/path/to/modwall/types/**/*"]
}
npx -y -p typescript@5.6.3 tsc -p /path/to/tsconfig.json
Requires Claude Code 2.1.287 or newer. Tested on 2.1.293.
RadTech builds apps, web products and applied AI, and advises founders. We do AI advisory, building and consulting.
Hire us: https://cal.com/radtech-solutions-yjxizt/15min
MIT, see LICENSE.
hooks/register.tsx 318 lines1import { update } from 'claude-code'
2import type { EngineInterface, PluginRegisterInput, Register } from 'claude-code'
3
4import type { ModwallAllow, ModwallLogEntry, ModwallMode, ModwallSeen } from '../types'
5import { decide, isBlocked, pad, reachOf, resolveTarget, riskOf, usesFromReport, versionOf } from './classify'
6import type { ValidateReport } from './classify'
7
8// modwall keeps its own reach small on purpose: it reads no prompt, gates no
9// tool call and makes no network request. It judges other mods as they load,
10// keeps its lists in $.store, registers /modwall, and runs a host command
11// only for /modwall scan.
12
13const seenRef = { plugin: 'modwall', key: 'seen' } as const
14const startedRef = { plugin: 'modwall', key: 'isStarted' } as const
15
16const LOG_CAP = 500
17const MODES: readonly ModwallMode[] = ['audit', 'ask', 'allowlist']
18
19function modeOf(options: Readonly<Record<string, unknown>>): ModwallMode {
20 const m = options.mode
21 return MODES.includes(m as ModwallMode) ? (m as ModwallMode) : 'audit'
22}
23
24type Read<T> = { items: T[]; isBroken: boolean }
25
26const isAllowEntry = (v: unknown): v is ModwallAllow =>
27 typeof v === 'object' && v !== null && typeof (v as ModwallAllow).provenance === 'string' && typeof (v as ModwallAllow).version === 'string'
28const isString = (v: unknown): v is string => typeof v === 'string'
29
30/** A stored list: unset reads as empty; anything present that is not a list of the right shape is broken. */
31async function readList<T>($: EngineInterface, key: string, isItem: (v: unknown) => v is T): Promise<Read<T>> {
32 const v = await $.store.get(key)
33 if (v === undefined) return { items: [], isBroken: false }
34 if (!Array.isArray(v) || !v.every(isItem)) return { items: Array.isArray(v) ? v.filter(isItem) : [], isBroken: true }
35 return { items: v, isBroken: false }
36}
37
38async function readSeen($: EngineInterface): Promise<ModwallSeen[]> {
39 const { value } = await $.state.get(seenRef)
40 return value ?? []
41}
42
43async function judge($: EngineInterface, e: PluginRegisterInput, mode: ModwallMode) {
44 const reach = reachOf(e.uses, e.name)
45 const risk = riskOf(reach)
46 const allow = await readList($, 'allow', isAllowEntry)
47 const block = await readList($, 'block', isString)
48 const known = await readList($, 'known', isString)
49 const id = `${e.provenance}#${versionOf(e)}`
50 const verdict = decide(mode, e, risk, allow.items, block.items, known.items.includes(id), allow.isBroken || block.isBroken)
51
52 // The verdict stands from here on; recording it must not change it.
53 try {
54 await record($, e, reach, risk, verdict, id, known)
55 } catch {
56 // A failed record leaves the verdict as decided.
57 }
58 return verdict
59}
60
61async function record(
62 $: EngineInterface,
63 e: PluginRegisterInput,
64 reach: ModwallSeen['reach'],
65 risk: ModwallSeen['risk'],
66 verdict: ReturnType<typeof decide>,
67 id: string,
68 known: Read<string>,
69) {
70 const at = await $.clock.now()
71 const { value: isStarted = false } = await $.state.get(startedRef)
72 const row: ModwallSeen = {
73 name: e.name,
74 provenance: e.provenance,
75 version: versionOf(e),
76 tier: e.tier,
77 root: e.root,
78 reach,
79 risk,
80 status: verdict.status,
81 reason: verdict.reason,
82 isNotified: !verdict.shouldNotify,
83 at,
84 }
85 if (verdict.shouldNotify && isStarted) {
86 $.ui.toast(toastText(row), { timeoutMs: 10000 })
87 row.isNotified = true
88 }
89 await update($, seenRef, prev => [...(prev ?? []).filter(s => s.provenance !== e.provenance), row])
90
91 if (e.tier !== 'user') return
92 if (!known.isBroken && !known.items.includes(id)) await $.store.set('known', [...known.items, id].slice(-2000))
93 const log = await $.store.get('log')
94 if (log !== undefined && !Array.isArray(log)) return // damaged: leave it for the person to see
95 const entry: ModwallLogEntry = { at, name: e.name, provenance: e.provenance, version: row.version, risk, status: verdict.status, reason: verdict.reason }
96 await $.store.set('log', [...(log ?? []), entry].slice(-LOG_CAP))
97}
98
99function toastText(s: ModwallSeen): string {
100 const what = s.reach.length ? s.reach.join(', ') : 'nothing notable'
101 if (s.status === 'flagged') return `modwall: new high-risk mod ${s.name} (${what}). /modwall to review`
102 if (s.status === 'check-failed') return `modwall: its stored lists are damaged (${s.name}: ${s.reason}). /modwall lists`
103 return `modwall: ${s.status} ${s.name} (${s.risk}: ${what})`
104}
105
106async function flushToasts($: EngineInterface) {
107 const seen = await readSeen($)
108 const pending = seen.filter(s => !s.isNotified)
109 for (const s of pending) $.ui.toast(toastText(s), { timeoutMs: 10000 })
110 if (pending.length) await update($, seenRef, prev => (prev ?? []).map(s => ({ ...s, isNotified: true })))
111}
112
113async function brokenKeys($: EngineInterface): Promise<string[]> {
114 const out: string[] = []
115 if ((await readList($, 'allow', isAllowEntry)).isBroken) out.push('allow')
116 if ((await readList($, 'block', isString)).isBroken) out.push('block')
117 if ((await readList($, 'known', isString)).isBroken) out.push('known')
118 const log = await $.store.get('log')
119 if (log !== undefined && !Array.isArray(log)) out.push('log')
120 return out
121}
122
123function brokenLine(keys: readonly string[]): string[] {
124 if (keys.length === 0) return []
125 return [
126 `WARNING: stored ${keys.join(', ')} ${keys.length > 1 ? 'are' : 'is'} damaged (present but not a list of the right shape).`,
127 'ask and allowlist mode refuse every user mod until this is fixed; audit mode cannot apply a damaged blocklist.',
128 ]
129}
130
131function table(seen: readonly ModwallSeen[], mode: ModwallMode, broken: readonly string[]): string {
132 if (seen.length === 0) {
133 return [
134 ...brokenLine(broken),
135 `modwall (${mode} mode): no other mod loaded after modwall this session.`,
136 'modwall only sees mods that load after it. See the README on load order, or run /modwall scan.',
137 ].join('\n')
138 }
139 const head = `${pad('MOD', 22)} ${pad('VERSION', 12)} ${pad('RISK', 6)} ${pad('DECISION', 16)} REACH`
140 const rows = seen.map(
141 s => `${pad(s.name, 22)} ${pad(s.version, 12)} ${pad(s.risk, 6)} ${pad(s.status, 16)} ${s.reach.join(', ') || '-'}`,
142 )
143 return [...brokenLine(broken), `modwall (${mode} mode), ${seen.length} mod(s) seen this session:`, head, ...rows].join('\n')
144}
145
146function logText(log: unknown): string {
147 if (log !== undefined && !Array.isArray(log)) return 'modwall: the stored log is damaged (not a list); nothing is appended to it until it is fixed.'
148 if (!Array.isArray(log) || log.length === 0) return 'modwall log is empty.'
149 const str = (v: unknown, fallback = '?') => (typeof v === 'string' ? v : fallback)
150 const rows = log.slice(-30).map(raw => {
151 if (typeof raw !== 'object' || raw === null) return '(unreadable entry)'
152 const l = raw as Record<string, unknown>
153 const at = typeof l.at === 'number' && Number.isFinite(l.at) ? new Date(l.at).toISOString() : '?'
154 return `${at} ${pad(str(l.name), 22)} ${pad(str(l.version), 12)} ${pad(str(l.risk), 6)} ${pad(str(l.status), 16)} ${str(l.reason, '')}`
155 })
156 return [`modwall log, last ${rows.length} of ${log.length}:`, ...rows].join('\n')
157}
158
159async function allowCmd($: EngineInterface, arg: string): Promise<string> {
160 const target = resolveTarget(await readSeen($), arg)
161 if (typeof target === 'string') return target
162 const allowRead = await readList($, 'allow', isAllowEntry)
163 const blockRead = await readList($, 'block', isString)
164 if (allowRead.isBroken || blockRead.isBroken) return 'modwall: the stored lists are damaged; not changing them. See /modwall lists.'
165 const at = await $.clock.now()
166 const allow = allowRead.items.filter(a => !(a.provenance === target.provenance && a.version === target.version))
167 await $.store.set('allow', [...allow, { provenance: target.provenance, version: target.version, at }])
168 const name = target.provenance.split('@')[0] ?? target.provenance
169 await $.store.set('block', blockRead.items.filter(b => b !== target.provenance && b !== name))
170 const which = target.isAnyVersion ? 'at ANY version (*), including future updates' : `at version ${target.version} only`
171 return `modwall: allowed ${target.provenance} ${which}. Run /reload-plugins to load it if it was held.`
172}
173
174async function blockCmd($: EngineInterface, arg: string): Promise<string> {
175 const target = resolveTarget(await readSeen($), arg)
176 const key = typeof target === 'string' ? (arg.split(/\s+/)[0] ?? '') : target.provenance
177 if (!key) return 'modwall: say which mod, as /modwall block <name>.'
178 const blockRead = await readList($, 'block', isString)
179 const allowRead = await readList($, 'allow', isAllowEntry)
180 if (allowRead.isBroken || blockRead.isBroken) return 'modwall: the stored lists are damaged; not changing them. See /modwall lists.'
181 if (!blockRead.items.includes(key)) await $.store.set('block', [...blockRead.items, key])
182 await $.store.set('allow', allowRead.items.filter(a => a.provenance !== key))
183 return `modwall: blocked ${key}. It is refused from the next load on; run /reload-plugins to drop it now.`
184}
185
186async function unblockCmd($: EngineInterface, arg: string): Promise<string> {
187 const key = arg.split(/\s+/)[0] ?? ''
188 const target = resolveTarget(await readSeen($), key)
189 const prov = typeof target === 'string' ? undefined : target.provenance
190 const blockRead = await readList($, 'block', isString)
191 if (blockRead.isBroken) return 'modwall: the stored blocklist is damaged; not changing it. See /modwall lists.'
192 const kept = blockRead.items.filter(b => b !== key && b !== prov)
193 await $.store.set('block', kept)
194 return kept.length === blockRead.items.length ? `modwall: ${key} was not blocked.` : `modwall: unblocked ${key}.`
195}
196
197async function listsText($: EngineInterface): Promise<string> {
198 const allow = await readList($, 'allow', isAllowEntry)
199 const block = await readList($, 'block', isString)
200 return [
201 ...brokenLine(await brokenKeys($)),
202 `allowlist (${allow.items.length}): ${allow.items.map(a => `${a.provenance} ${a.version}`).join('; ') || 'empty'}`,
203 `blocklist (${block.items.length}): ${block.items.join('; ') || 'empty'}`,
204 ].join('\n')
205}
206
207type Listed = { id: string; version?: string; enabled?: boolean; installPath?: string; readFromFolder?: string; mcpServers?: Record<string, unknown> }
208
209/** A folder scan will hand to `claude plugin validate`: absolute, and never read as a flag. */
210function isSafeRoot(root: string): boolean {
211 return root.startsWith('/') && !root.startsWith('-') && !root.includes('\0')
212}
213
214async function scan($: EngineInterface): Promise<string> {
215 const listed = await $.process.run(['claude', 'plugin', 'list', '--json'], { timeoutMs: 30000 }).catch(() => undefined)
216 if (!listed || listed.exitCode !== 0) return 'modwall scan: could not run `claude plugin list --json`.'
217 let plugins: Listed[]
218 try {
219 const parsed: unknown = JSON.parse(listed.stdout)
220 if (!Array.isArray(parsed)) throw new Error('not a list')
221 plugins = parsed.filter((p): p is Listed => typeof p === 'object' && p !== null && typeof (p as Listed).id === 'string')
222 } catch {
223 return 'modwall scan: `claude plugin list --json` did not return a JSON list.'
224 }
225 const lines = [`modwall scan: ${plugins.length} installed plugin(s)`]
226 lines.push(`${pad('PLUGIN', 40)} ${pad('ON', 3)} ${pad('RISK', 6)} FOOTPRINT`)
227 for (const p of plugins) {
228 const root = p.readFromFolder ?? p.installPath
229 const mcp = Object.keys(p.mcpServers ?? {})
230 let risk = '-'
231 const foot: string[] = []
232 if (root && !isSafeRoot(root)) {
233 foot.push(`skipped: folder is not an absolute path (${JSON.stringify(root)})`)
234 } else if (root) {
235 const out = await $.process.run(['claude', 'plugin', 'validate', '--json', root], { timeoutMs: 30000 }).catch(() => undefined)
236 let report: ValidateReport | undefined
237 try {
238 report = out ? (JSON.parse(out.stdout) as ValidateReport) : undefined
239 } catch {
240 report = undefined
241 }
242 const uses = usesFromReport(report ?? {})
243 const hasHooks = (report?.contents ?? []).some(c => c.type === 'hooks')
244 if (uses.events.length || uses.calls.length) {
245 const reach = reachOf(uses, p.id.split('@')[0])
246 risk = riskOf(reach)
247 foot.push(`mod: ${reach.join(', ') || 'known-safe uses only'}`)
248 } else if (hasHooks) {
249 foot.push('hooks.json without a hooks module (command hooks run as shell commands; not judged by plugin.register)')
250 }
251 if (!report) foot.push('validate failed')
252 }
253 if (mcp.length) foot.push(`MCP: ${mcp.join(', ')}`)
254 lines.push(`${pad(p.id, 40)} ${pad(p.enabled === false ? 'no' : 'yes', 3)} ${pad(risk, 6)} ${foot.join('; ') || 'no mod, no MCP servers'}`)
255 }
256 return lines.join('\n')
257}
258
259const HELP = [
260 '/modwall mods seen this session: reach, risk, decision',
261 '/modwall allow <name> [version|*] allow a mod (a name seen this session, or name@marketplace; no version there means any version)',
262 '/modwall block <name> refuse a mod at every load',
263 '/modwall unblock <name>',
264 '/modwall lists show the allowlist and blocklist',
265 '/modwall log the persistent decision log',
266 '/modwall scan validate every installed plugin and report its footprint',
267].join('\n')
268
269export const register: Register = (on, options) => {
270 const mode = modeOf(options)
271
272 on('plugin.register', async ($, e, next) => {
273 const verdict = await judge($, e, mode)
274 if (verdict.isRefused) return { refuse: verdict.reason }
275 return next(e)
276 }).catch(async ($, e, next) => {
277 if (next.called) return next(e)
278 if (e.tier !== 'user') return next(e)
279 if (mode !== 'audit') return { refuse: 'modwall could not check this mod, so it was not loaded' }
280 // Audit mode still honours the blocklist when the check fails, as far as
281 // it can be read here (a $ call rejects when this handler answers a re-entry).
282 let block: unknown
283 try {
284 block = await $.store.get('block')
285 } catch {
286 return next(e)
287 }
288 if (Array.isArray(block) && isBlocked(e, block.filter(isString))) {
289 return { refuse: `modwall: ${e.name} is on your blocklist` }
290 }
291 return next(e)
292 })
293
294 on('session.start', async ($, e, next) => {
295 await $.command.register({
296 name: 'modwall',
297 description: 'Firewall for mods: what each loaded mod can reach, and allow/block/log/scan',
298 argumentHint: '[allow|block|unblock|lists|log|scan] [name]',
299 })
300 await $.state.set(startedRef, true)
301 await flushToasts($)
302 return next(e)
303 })
304
305 on('command.run', { command: 'modwall' }, async ($, e) => {
306 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
307 const arg = rest.join(' ')
308 if (verb === '') return { text: table(await readSeen($), mode, await brokenKeys($)) }
309 if (verb === 'allow' && arg) return { text: await allowCmd($, arg) }
310 if (verb === 'block' && arg) return { text: await blockCmd($, arg) }
311 if (verb === 'unblock' && arg) return { text: await unblockCmd($, arg) }
312 if (verb === 'lists') return { text: await listsText($) }
313 if (verb === 'log') return { text: logText(await $.store.get('log')) }
314 if (verb === 'scan') return { text: await scan($) }
315 return { text: HELP }
316 })
317}
318hooks/classify.ts 342 lines1// Pure functions: read a mod's scanned `uses`, name its reach, rate its risk,
2// and decide what the policy does with it. Nothing here touches `$`.
3//
4// The classifier is an allowlist. Only a small known-safe set of events and
5// calls is low risk; anything else, including anything this file does not
6// know, is high.
7
8import type {
9 ModwallAllow,
10 ModwallMode,
11 ModwallReach,
12 ModwallRisk,
13 ModwallSeen,
14 ModwallStatus,
15} from '../types'
16
17export type Uses = {
18 events: readonly string[]
19 calls: readonly string[]
20 env?: { reads: readonly string[]; writes: readonly string[] }
21 state?: { reads: readonly { plugin: string }[]; writes: readonly { plugin: string }[] }
22}
23
24// Events a mod may hook and stay low risk. `command.run(own)` and
25// `ui.render(pane)` are not event names: /modwall scan writes them when the
26// validator shows the hook is matched to the mod's own command, or to the
27// AbovePrompt band or a Pane. At load the matcher is not visible, so a bare
28// `command.run` or `ui.render` is high.
29const SAFE_EVENTS = ['session.start', 'session.end', 'turn.start', 'command.run(own)', 'ui.render(pane)']
30
31const SAFE_CALLS = [
32 'ui.notice',
33 'ui.invalidate',
34 'ui.blit',
35 'ui.resolve',
36 'ui.log',
37 'ui.toast',
38 'ui.status',
39 'ui.open',
40 'ui.close',
41 'ui.panes',
42 'ui.scroll',
43 'ui.focus',
44 'store.get',
45 'store.set',
46 'store.delete',
47 'store.keys',
48 'state.get',
49 'state.set',
50 'command.register',
51 // read-only session facts: the folder, the model, the surface, the version
52 'session.cwd',
53 'session.root',
54 'session.model',
55 'session.turns',
56 'session.id',
57 'session.surfaces',
58 'session.surface',
59 'session.version',
60]
61
62const EVENT_GROUPS: readonly [ModwallReach, readonly string[]][] = [
63 [
64 'prompts',
65 [
66 'prompt.submit',
67 'prompt.mention',
68 'prompt.fill',
69 'prompt.edit',
70 'prompt.attachment',
71 'session.append',
72 'session.send',
73 'session.receive',
74 'session.compact',
75 'turn.step',
76 'turn.complete',
77 'model.complete',
78 'model.fork',
79 'classic.UserPromptSubmit',
80 ],
81 ],
82 ['tool-gate', ['tool.call', 'tool.check', 'tool.describe', 'tool.list', 'tool.register', 'classic.PreToolUse', 'classic.PostToolUse', 'classic.PermissionRequest']],
83 ['system-prompt', ['prompt.section', 'prompt.compose', 'prompt.context', 'skill.prompt']],
84 ['other-mods', ['plugin.register', 'engine.create', 'state.get', 'state.set']],
85 ['agents', ['agent.spawn', 'agent.offer']],
86 ['config', ['config.set', 'config.describe', 'settings.read']],
87 ['commands', ['command.run', 'command.describe']],
88 ['screen', ['ui.render', 'ui.input', 'ui.press', 'ui.select', 'ui.copy', 'ui.focus']],
89]
90
91// A hook on a `$` call's name sees, and can rewrite or refuse, that call for
92// every other mod (and for modwall's own store reads).
93const API_NOUNS = ['fs.', 'http.', 'process.', 'store.', 'env.', 'clock.', 'mcp.', 'audio.', 'command.register', 'ui.']
94
95const CALL_GROUPS: readonly [ModwallReach, readonly string[]][] = [
96 ['network', ['http.']],
97 ['processes', ['process.']],
98 ['file-read', ['fs.read', 'fs.list', 'fs.stat', 'fs.exists', 'fs.ancestors']],
99 ['file-write', ['fs.write']],
100 ['tool-calls', ['tool.', 'mcp.']],
101 ['prompts', ['session.messages', 'session.append', 'model.', 'prompt.']],
102 ['agents', ['agent.']],
103 ['commands', ['command.run', 'command.list']],
104 ['screen', ['ui.ask', 'ui.copy', 'ui.selection']],
105]
106
107const SECRET_NAME = /KEY|TOKEN|SECRET|PASS|AUTH|CRED|COOKIE|SESSION/i
108
109/** Whether one `on(...)` pattern, as written, would receive `event`. */
110export function patternHits(pattern: string, event: string): boolean {
111 if (pattern.startsWith('!')) return pattern.slice(1) !== event
112 if (pattern === '*') return !event.startsWith('telemetry.')
113 if (pattern.endsWith('.*')) return event.startsWith(pattern.slice(0, -1))
114 return pattern === event
115}
116
117function isWildcard(p: string): boolean {
118 return p === '*' || p.startsWith('!') || p.endsWith('.*')
119}
120
121function startsAny(s: string, prefixes: readonly string[]): boolean {
122 return prefixes.some(p => (p.endsWith('.') ? s.startsWith(p) : s === p))
123}
124
125/** The reach a mod has, each label once, in the order found. */
126export function reachOf(uses: Uses, name?: string): ModwallReach[] {
127 const reach = new Set<ModwallReach>()
128
129 for (const p of uses.events) {
130 if (SAFE_EVENTS.includes(p)) continue
131 if (p.startsWith('telemetry.')) {
132 reach.add('telemetry')
133 continue
134 }
135 if (isWildcard(p)) {
136 reach.add('wildcard')
137 for (const [label, events] of EVENT_GROUPS) if (events.some(ev => patternHits(p, ev))) reach.add(label)
138 continue
139 }
140 const group = EVENT_GROUPS.find(([, events]) => events.includes(p))
141 if (group) reach.add(group[0])
142 else if (startsAny(p, API_NOUNS)) reach.add('intercepts-api')
143 else reach.add('unknown')
144 }
145
146 for (const c of uses.calls) {
147 if (SAFE_CALLS.includes(c) || c.startsWith('clock.')) continue
148 if (c === 'env.get') continue // judged from env.reads below
149 if (c === 'env.set') {
150 reach.add('env-write')
151 continue
152 }
153 const group = CALL_GROUPS.find(([, prefixes]) => startsAny(c, prefixes))
154 reach.add(group ? group[0] : 'unknown')
155 }
156
157 const envReads = uses.env?.reads ?? []
158 if (envReads.some(n => SECRET_NAME.test(n))) reach.add('secret-names')
159 else if (envReads.length > 0 || uses.calls.includes('env.get')) reach.add('env')
160
161 if (name !== undefined) {
162 const refs = [...(uses.state?.reads ?? []), ...(uses.state?.writes ?? [])]
163 if (refs.some(r => r.plugin !== name)) reach.add('other-mods')
164 }
165 return [...reach]
166}
167
168/** Low: only known-safe uses. Medium: reads env vars whose names do not look secret. High: anything else. */
169export function riskOf(reach: readonly ModwallReach[]): ModwallRisk {
170 if (reach.length === 0) return 'low'
171 if (reach.every(r => r === 'env')) return 'medium'
172 return 'high'
173}
174
175export type Mod = { name: string; provenance: string; version?: string; tier: string }
176
177export type Verdict = { isRefused: boolean; status: ModwallStatus; reason: string; shouldNotify: boolean }
178
179export function versionOf(mod: { version?: string }): string {
180 return mod.version ?? 'unversioned'
181}
182
183export function isAllowlisted(mod: Mod, allow: readonly ModwallAllow[]): boolean {
184 const v = versionOf(mod)
185 return allow.some(a => a.provenance === mod.provenance && (a.version === '*' || a.version === v))
186}
187
188export function isBlocked(mod: Mod, block: readonly string[]): boolean {
189 return block.includes(mod.provenance) || block.includes(mod.name)
190}
191
192/**
193 * What the policy does with one mod. Only the `user` tier is judged: the
194 * mods an organization lists and the ones built into the binary pass.
195 * `isListBroken` says a stored list was present but unreadable: then
196 * ask and allowlist refuse, and audit allows but says so.
197 */
198export function decide(
199 mode: ModwallMode,
200 mod: Mod,
201 risk: ModwallRisk,
202 allow: readonly ModwallAllow[],
203 block: readonly string[],
204 isKnown: boolean,
205 isListBroken = false,
206): Verdict {
207 if (mod.tier !== 'user') {
208 return { isRefused: false, status: 'not-judged', reason: `tier ${mod.tier} is outside modwall's reach`, shouldNotify: false }
209 }
210 if (isBlocked(mod, block)) {
211 return { isRefused: true, status: 'blocked', reason: `modwall: ${mod.name} is on your blocklist`, shouldNotify: true }
212 }
213 if (isListBroken) {
214 if (mode !== 'audit') {
215 return {
216 isRefused: true,
217 status: 'check-failed',
218 reason: `modwall: its stored lists are damaged, so ${mod.name} was not loaded (/modwall lists)`,
219 shouldNotify: true,
220 }
221 }
222 return { isRefused: false, status: 'check-failed', reason: 'stored lists are damaged; allowed in audit mode', shouldNotify: true }
223 }
224 const isAllowed = isAllowlisted(mod, allow)
225 if (mode === 'allowlist' && !isAllowed) {
226 return {
227 isRefused: true,
228 status: 'not-allowlisted',
229 reason: `modwall: ${mod.provenance} ${versionOf(mod)} is not on the allowlist (/modwall allow ${mod.name}, then /reload-plugins)`,
230 shouldNotify: true,
231 }
232 }
233 if (mode === 'ask' && risk === 'high' && !isAllowed) {
234 return {
235 isRefused: true,
236 status: 'held',
237 reason: `modwall: high-risk mod ${mod.provenance} ${versionOf(mod)} is held until you approve it (/modwall allow ${mod.name}, then /reload-plugins)`,
238 shouldNotify: true,
239 }
240 }
241 if (risk === 'high' && !isAllowed) {
242 return { isRefused: false, status: 'flagged', reason: 'high risk, allowed in audit mode', shouldNotify: !isKnown }
243 }
244 return { isRefused: false, status: 'allowed', reason: isAllowed ? 'on the allowlist' : `${risk} risk`, shouldNotify: false }
245}
246
247/** Splits a validator list on top-level commas, keeping `{...}` matchers whole. */
248function splitTop(list: string): string[] {
249 const out: string[] = []
250 let depth = 0
251 let cur = ''
252 for (const ch of list) {
253 if (ch === '{') depth++
254 if (ch === '}') depth = Math.max(0, depth - 1)
255 if (ch === ',' && depth === 0) {
256 out.push(cur.trim())
257 cur = ''
258 } else cur += ch
259 }
260 if (cur.trim()) out.push(cur.trim())
261 return out.filter(s => s !== 'nothing')
262}
263
264function stripMatcher(s: string): string {
265 const i = s.indexOf('{')
266 return (i < 0 ? s : s.slice(0, i)).trim()
267}
268
269const PANE_MATCHER = /^ui\.render\{component=(AbovePrompt|Pane)\}$/
270
271export type ValidateReport = {
272 contents?: { type?: string; notes?: readonly string[]; gatingHooks?: readonly { hook?: string; pattern?: string }[] }[]
273}
274
275/**
276 * Reads `uses` back out of a `claude plugin validate --json` report: the
277 * hooks, calls and env reads in its notes, plus every gating hook from the
278 * structured `gatingHooks` field. A `command.run` hook the validator says
279 * answers the mod's own command becomes `command.run(own)`, and a
280 * `ui.render` matched to AbovePrompt or Pane becomes `ui.render(pane)`.
281 */
282export function usesFromReport(report: ValidateReport): Uses {
283 const hooks = (report.contents ?? []).filter(c => c.type === 'hooks')
284 const notes = hooks.flatMap(c => c.notes ?? [])
285 const own = new Set<string>()
286 for (const note of notes) {
287 const m = /^\S+ answers its own command: (.*)$/.exec(note)
288 if (m) for (const item of splitTop(m[1] ?? '')) own.add(item)
289 }
290 const events: string[] = []
291 const calls: string[] = []
292 const reads: string[] = []
293 const stateReads: { plugin: string }[] = []
294 const stateWrites: { plugin: string }[] = []
295 for (const note of notes) {
296 const m = /^\S+ (hooks|calls|env reads|state reads|state writes): (.*)$/.exec(note)
297 if (!m) continue
298 const kind = m[1]
299 const list = (m[2] ?? '').replace(/\s\(via [^)]*\)/g, '')
300 for (const item of splitTop(list)) {
301 if (kind === 'hooks') {
302 if (own.has(item)) events.push('command.run(own)')
303 else if (PANE_MATCHER.test(item)) events.push('ui.render(pane)')
304 else events.push(stripMatcher(item))
305 } else if (kind === 'calls') calls.push(item.replace(/^\$\./, ''))
306 else if (kind === 'env reads') reads.push(item)
307 else (kind === 'state reads' ? stateReads : stateWrites).push({ plugin: item.split('.')[0] ?? item })
308 }
309 }
310 for (const g of hooks.flatMap(c => c.gatingHooks ?? [])) {
311 const ev = stripMatcher(g.pattern ?? g.hook ?? '')
312 if (ev && !events.includes(ev)) events.push(ev)
313 }
314 return {
315 events: [...new Set(events)],
316 calls: [...new Set(calls)],
317 env: reads.length ? { reads, writes: [] } : undefined,
318 state: stateReads.length || stateWrites.length ? { reads: stateReads, writes: stateWrites } : undefined,
319 }
320}
321
322export type Target = { provenance: string; version: string; isAnyVersion: boolean }
323
324/** Finds the provenance and version for what the person typed, or says why it cannot. */
325export function resolveTarget(seen: readonly Pick<ModwallSeen, 'name' | 'provenance' | 'version'>[], arg: string): Target | string {
326 const [who = '', ver] = arg.split(/\s+/)
327 const byProvenance = seen.find(s => s.provenance === who)
328 if (byProvenance) return { provenance: byProvenance.provenance, version: ver ?? byProvenance.version, isAnyVersion: ver === '*' }
329 const byName = seen.filter(s => s.name === who)
330 if (byName.length > 1) {
331 return `modwall: more than one mod named "${who}" was seen (${byName.map(s => s.provenance).join(', ')}). Give the one you mean as name@marketplace.`
332 }
333 const hit = byName[0]
334 if (hit) return { provenance: hit.provenance, version: ver ?? hit.version, isAnyVersion: ver === '*' }
335 if (who.includes('@')) return { provenance: who, version: ver ?? '*', isAnyVersion: (ver ?? '*') === '*' }
336 return `modwall: no mod named "${who}" was seen this session. Give its id instead, as name@marketplace [version].`
337}
338
339export function pad(s: string, n: number): string {
340 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
341}
342types/index.d.ts 72 lines1// modwall's contract: the shapes it keeps in $.state and $.store.
2
3export type ModwallRisk = 'low' | 'medium' | 'high'
4
5export type ModwallReach =
6 | 'prompts'
7 | 'network'
8 | 'processes'
9 | 'env'
10 | 'env-write'
11 | 'secret-names'
12 | 'tool-gate'
13 | 'tool-calls'
14 | 'system-prompt'
15 | 'file-write'
16 | 'file-read'
17 | 'other-mods'
18 | 'agents'
19 | 'config'
20 | 'commands'
21 | 'screen'
22 | 'intercepts-api'
23 | 'telemetry'
24 | 'wildcard'
25 | 'unknown'
26
27export type ModwallMode = 'audit' | 'ask' | 'allowlist'
28
29export type ModwallStatus =
30 | 'allowed'
31 | 'flagged'
32 | 'held'
33 | 'blocked'
34 | 'not-allowlisted'
35 | 'not-judged'
36 | 'check-failed'
37
38/** One mod as modwall saw it load in this session. */
39export type ModwallSeen = {
40 name: string
41 provenance: string
42 version: string
43 tier: string
44 root: string
45 reach: ModwallReach[]
46 risk: ModwallRisk
47 status: ModwallStatus
48 reason: string
49 isNotified: boolean
50 at: number
51}
52
53/** An allowlist entry: a provenance at one version, or at any version ('*'). */
54export type ModwallAllow = { provenance: string; version: string; at: number }
55
56/** One line of the persistent decision log. */
57export type ModwallLogEntry = {
58 at: number
59 name: string
60 provenance: string
61 version: string
62 risk: ModwallRisk
63 status: ModwallStatus
64 reason: string
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 modwall: { seen: ModwallSeen[]; isStarted: boolean }
70 }
71}
72