Blocks destructive tool calls before they run (force pushes, rm -rf outside the project, DROP TABLE, terraform destroy, writes to .env and keys, ...) and…

A guard on Claude Code's tool calls: destructive shell commands and writes to secret files are refused before they run, and Claude is told why.
/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install safety-net@mod-squad
Blocked shell commands (also inside && / ; / | chains, after sudo/env/VAR=x, and inside bash -c '...'):
git push --force / -f / +branch (--force-with-lease is allowed), git reset --hard, git clean -fd / -fdx, git branch -D main|masterrm -rf (or -fr, -r -f, --recursive --force) on /, ~, $HOME, *, .., the project root itself, or any absolute path outside the project. rm -rf node_modules, rm -rf ./dist and other in-project paths pass.DROP DATABASE / DROP TABLE / DROP SCHEMA / TRUNCATE sent to psql, mysql, sqlcmd, sqlite3 and friendsterraform destroy (and apply -destroy), kubectl delete namespace|ns, chmod -R 777curl … | sh / wget … | bash, bash <(curl …), sh -c "$(curl …)"mkfs*, dd of=/dev/… (except /dev/null and friends)Protected files (Edit / Write / MultiEdit / NotebookEdit): .env, .env.* (but not .env.example/.sample/.template), *.pem, *.key, *.p12, *.pfx, id_rsa*, id_ed25519*, anything under .git/, secrets.*, credentials*, .npmrc, .pypirc.
Each block shows a toast, and the status line keeps a count (🛡 safety-net: 2 blocked).
/safety-net (status and the last few blocks), /safety-net off (disable for this session), /safety-net on.extraProtected — comma-separated globs for more protected files (*.tfstate, config/prod.*; no slash = match the file name). allowForceWithLease (default true).What it is not: this is a pattern-based guard, not a sandbox. It reads the command text, so a determined script (a variable holding the path, a command written to a file and run later, an alias, a language runtime deleting files) gets past it. It catches the common accidents; keep your backups and permission settings.
Test: claude plugin test mods/safety-net. Validate: claude plugin validate mods/safety-net.
hooks/register.ts 88 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Block } from '../types'
5import { classifyCommand, denyText, parseExtra, protectedFile } from './rules'
6import type { Verdict } from './rules'
7
8const RECENT = 5
9const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
10
11const enabled = atom({ plugin: 'safety-net', key: 'enabled' } as const, true)
12const blocked = atom({ plugin: 'safety-net', key: 'blocked' } as const, 0)
13const recent = atom({ plugin: 'safety-net', key: 'recent' } as const, [] as Block[])
14
15type Options = { extraProtected?: string; allowForceWithLease?: boolean }
16
17/** Judges one tool call; null lets it run. */
18async function judge($: EngineInterface, e: Record<string, unknown>, opts: Options): Promise<Verdict | null> {
19 const tool = String(e.tool)
20 if (tool === 'Bash' && typeof e.command === 'string') {
21 const root = await $.session.root()
22 const cwd = await $.session.cwd()
23 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined
24 return classifyCommand(e.command, { root, cwd, home, allowForceWithLease: opts.allowForceWithLease !== false })
25 }
26 if (FILE_TOOLS.has(tool)) {
27 const path = 'file_path' in e ? e.file_path : 'notebook_path' in e ? e.notebook_path : undefined
28 if (typeof path === 'string') return protectedFile(path, parseExtra(opts.extraProtected))
29 }
30 return null
31}
32
33async function recordBlock($: EngineInterface, tool: string, v: Verdict) {
34 const at = await $.clock.now()
35 const n = await update($, blocked, x => x + 1)
36 await update($, recent, list => [...list, { at, tool, what: v.what, why: v.why }].slice(-RECENT))
37 $.ui.toast(`🛡 safety-net blocked ${v.what}`, { timeoutMs: 6000 })
38 $.ui.status(`🛡 safety-net: ${n} blocked`)
39}
40
41async function setEnabled($: EngineInterface, on: boolean) {
42 await update($, enabled, () => on)
43 const n = await read($, blocked)
44 $.ui.status(on ? (n > 0 ? `🛡 safety-net: ${n} blocked` : undefined) : '🛡 safety-net: off')
45}
46
47export const register: Register = (on, options) => {
48 const opts = (options ?? {}) as Options
49
50 on('session.start', async ($, e, next) => {
51 await $.command.register({
52 name: 'safety-net',
53 description: 'Safety net: show recent blocks, or turn the guard off/on for this session',
54 argumentHint: '[on | off]',
55 immediate: true,
56 })
57 return next(e)
58 })
59
60 on('tool.call', async ($, e, next) => {
61 if (!(await read($, enabled))) return next(e)
62 const verdict = await judge($, e as unknown as Record<string, unknown>, opts)
63 if (!verdict) return next(e)
64 await recordBlock($, String(e.tool), verdict)
65 return { deny: denyText(verdict) }
66 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'safety-net: its guard failed.' }))
67
68 on('command.run', { command: 'safety-net' }, async ($, e) => {
69 const verb = e.args.trim().toLowerCase()
70 if (verb === 'off') {
71 await setEnabled($, false)
72 return { text: 'Safety net: off for this session. Destructive commands and protected-file writes will run unchecked. /safety-net on to re-enable.' }
73 }
74 if (verb === 'on') {
75 await setEnabled($, true)
76 return { text: 'Safety net: on.' }
77 }
78 if (verb !== '') return { text: 'Usage: /safety-net [on | off]' }
79
80 const isOn = await read($, enabled)
81 const n = await read($, blocked)
82 const list = await read($, recent)
83 const lines = [`Safety net: ${isOn ? 'on' : 'off'} · ${n} blocked this session.`]
84 for (const b of [...list].reverse()) lines.push(` ${new Date(b.at).toLocaleTimeString()} ${b.tool}: ${b.what} — ${b.why}`)
85 return { text: lines.join('\n') }
86 })
87}
88hooks/rules.ts 417 lines1// Pure classification for safety-net: no engine calls, so it is unit-tested directly.
2
3export type Verdict = { what: string; why: string }
4
5export type CommandContext = {
6 /** Project root, absolute. Paths inside it are fair game for `rm -rf`. */
7 root: string
8 /** Directory relative paths resolve against (the shell's cwd); defaults to root. */
9 cwd?: string
10 /** Home directory, when known; `~` and `$HOME` expand to it. */
11 home?: string
12 /** Let `git push --force-with-lease` through (default true). */
13 allowForceWithLease?: boolean
14}
15
16// ---------------------------------------------------------------- shell parsing
17
18type Segment = { words: string[]; raw: string; op: string }
19
20/**
21 * Splits a command line into simple commands at `&&`, `||`, `;`, `|`, `&` and
22 * newlines, honouring quotes and backslashes. Words come back unquoted; each
23 * segment records the operator that ends it.
24 */
25export function splitCommand(input: string): Segment[] {
26 const segments: Segment[] = []
27 let words: string[] = []
28 let word = ''
29 let hasWord = false
30 let raw = ''
31 let quote: '"' | "'" | null = null
32 let subst = 0
33
34 const endWord = () => {
35 if (hasWord) words.push(word)
36 word = ''
37 hasWord = false
38 }
39 const endSegment = (op: string) => {
40 endWord()
41 if (words.length > 0) segments.push({ words, raw: raw.trim(), op })
42 words = []
43 raw = ''
44 }
45
46 for (let i = 0; i < input.length; i++) {
47 const c = input[i] as string
48 if (quote) {
49 raw += c
50 if (c === quote) quote = null
51 else if (c === '\\' && quote === '"' && i + 1 < input.length) {
52 word += input[++i]
53 raw += input[i]
54 } else word += c
55 continue
56 }
57 if (c === "'" || c === '"') {
58 quote = c
59 hasWord = true
60 raw += c
61 continue
62 }
63 if (c === '\\' && i + 1 < input.length) {
64 const n = input[++i] as string
65 raw += c + n
66 if (n !== '\n') (word += n), (hasWord = true)
67 continue
68 }
69 const two = input.slice(i, i + 2)
70 if (two === '&&' || two === '||') {
71 endSegment(two)
72 i++
73 continue
74 }
75 if (c === ';' || c === '\n' || c === '|' || (c === '&' && input[i + 1] !== '>' && input[i - 1] !== '>')) {
76 endSegment(c === '\n' ? ';' : c)
77 continue
78 }
79 // `$(…)`, `<(…)` stay inside the word; other parens are subshells and
80 // separate commands: `(cd x && rm -rf /)`.
81 if (c === '(' && /[$<>]$/.test(word)) subst++
82 else if (c === ')' && subst > 0) subst--
83 else if (c === '(' || c === ')') {
84 endSegment(';')
85 continue
86 }
87 raw += c
88 if (c === ' ' || c === '\t') endWord()
89 else (word += c), (hasWord = true)
90 }
91 endSegment('')
92 return segments
93}
94
95const WRAPPERS = new Set(['sudo', 'doas', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'ionice', 'builtin', 'then', 'do', 'else', '!', 'xargs'])
96// Wrapper options that take a value as the next word.
97const WRAPPER_ARG_FLAGS: Record<string, Set<string>> = {
98 sudo: new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U']),
99 doas: new Set(['-u', '-C']),
100 env: new Set(['-u', '-C', '-S']),
101 nice: new Set(['-n']),
102 ionice: new Set(['-c', '-n']),
103 xargs: new Set(['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a']),
104}
105
106/** Drops `sudo`, `env`, `FOO=bar` and similar prefixes, leaving the real command first. */
107export function stripPrefixes(words: string[]): string[] {
108 let w = words
109 for (let guard = 0; guard < 20 && w.length > 0; guard++) {
110 const head = w[0] as string
111 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
112 w = w.slice(1)
113 continue
114 }
115 const name = basename(head)
116 if (!WRAPPERS.has(name)) break
117 let i = 1
118 const argFlags = WRAPPER_ARG_FLAGS[name] ?? new Set<string>()
119 while (i < w.length) {
120 const x = w[i] as string
121 if (x === '--') {
122 i++
123 break
124 }
125 if (name === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(x)) {
126 i++
127 continue
128 }
129 if (!x.startsWith('-')) break
130 i += argFlags.has(x) ? 2 : 1
131 }
132 w = w.slice(i)
133 }
134 return w
135}
136
137function basename(p: string): string {
138 const parts = p.split('/')
139 return parts[parts.length - 1] ?? p
140}
141
142/** Short flag letters (`-rf` → r,f) and long flags of a word list, up to `--`. */
143function flagsOf(args: string[]): { short: Set<string>; long: Set<string>; operands: string[] } {
144 const short = new Set<string>()
145 const long = new Set<string>()
146 const operands: string[] = []
147 let done = false
148 for (const a of args) {
149 if (done) operands.push(a)
150 else if (a === '--') done = true
151 else if (a.startsWith('--')) long.add(a.split('=')[0] as string)
152 else if (a.startsWith('-') && a.length > 1) for (const ch of a.slice(1)) short.add(ch)
153 else operands.push(a)
154 }
155 return { short, long, operands }
156}
157
158// ---------------------------------------------------------------- paths
159
160function normalize(path: string): string {
161 const abs = path.startsWith('/')
162 const out: string[] = []
163 for (const part of path.split('/')) {
164 if (part === '' || part === '.') continue
165 if (part === '..') out.pop()
166 else out.push(part)
167 }
168 return (abs ? '/' : '') + out.join('/')
169}
170
171function isWithin(path: string, root: string): boolean {
172 const r = normalize(root)
173 return path === r || path.startsWith(r === '/' ? '/' : r + '/')
174}
175
176const TEMP_ROOTS = ['/tmp', '/var/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
177
178const HOME_RE = /^(~|\$HOME|\$\{HOME\})(?=\/|$)/
179
180/** Why `rm -rf <target>` is dangerous, or null when it stays inside the project. */
181export function rmTargetDanger(target: string, ctx: CommandContext): string | null {
182 const t = target.trim()
183 if (t === '') return null
184 const root = normalize(ctx.root)
185 const cwd = normalize(ctx.cwd ?? ctx.root)
186
187 if (/^\/+\*?$/.test(t) || t === '/.' || t === '/..') return 'that wipes the whole filesystem'
188 if (/^(~|\$HOME|\$\{HOME\})\/?\*?$/.test(t)) return 'that wipes your home directory'
189 if (t === '*' || t === './*' || t === '.*' || t === '*/') return 'a bare glob deletes everything in the current directory'
190
191 let abs: string
192 if (HOME_RE.test(t)) {
193 if (!ctx.home) return 'it is outside the project (in your home directory)'
194 abs = normalize(t.replace(HOME_RE, ctx.home))
195 } else if (t.startsWith('/')) abs = normalize(t)
196 else if (/^\$/.test(t)) return null // unknown variable: can't judge, let it pass
197 else abs = normalize(cwd + '/' + t)
198
199 // Strip trailing glob segments so `/etc/*` is judged as `/etc`.
200 const judged = normalize(abs.replace(/\/[^/]*[*?][^/]*$/, '')) || '/'
201 if (judged === '/') return 'that wipes the whole filesystem'
202 if (ctx.home && judged === normalize(ctx.home)) return 'that wipes your home directory'
203 if (judged === root) return 'that deletes the whole project'
204 // Scratch space under a temp directory is fair game (the temp root itself is not).
205 if (TEMP_ROOTS.some(r => judged !== r && isWithin(judged, r))) return null
206 if (!isWithin(judged, root)) return `${judged} is outside the project`
207 return null
208}
209
210// ---------------------------------------------------------------- commands
211
212const SQL_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlcmd', 'sqlite3', 'sqlite', 'pgcli', 'mycli', 'cockroach', 'clickhouse-client', 'duckdb', 'sql', 'snowsql', 'bq', 'osql', 'isql'])
213const SQL_DESTRUCTIVE = /\b(drop\s+(database|table|schema)|truncate(\s+table)?)\b/i
214const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'ash'])
215const SAFE_DD_TARGETS = /^\/dev\/(null|zero|stdout|stderr|tty|fd\/\d+)$/
216
217function git(args: string[], ctx: CommandContext): Verdict | null {
218 // Skip global options: -C <dir>, -c <k=v>, --git-dir=..., --no-pager, ...
219 let i = 0
220 while (i < args.length) {
221 const a = args[i] as string
222 if (a === '-C' || a === '-c' || a === '--git-dir' || a === '--work-tree' || a === '--namespace') i += 2
223 else if (a.startsWith('-')) i++
224 else break
225 }
226 const sub = args[i]
227 const rest = args.slice(i + 1)
228 const { short, long, operands } = flagsOf(rest)
229
230 if (sub === 'push') {
231 const lease = [...long].some(l => l === '--force-with-lease')
232 const force = long.has('--force') || short.has('f') || operands.some(o => o.startsWith('+') && o.length > 1)
233 if (force) return { what: '`git push --force`', why: 'it overwrites remote history others may have pulled; use --force-with-lease' }
234 if (lease && ctx.allowForceWithLease === false)
235 return { what: '`git push --force-with-lease`', why: 'force pushes are switched off by the allowForceWithLease setting' }
236 return null
237 }
238 if (sub === 'reset' && long.has('--hard'))
239 return { what: '`git reset --hard`', why: 'it throws away every uncommitted change with no undo' }
240 if (sub === 'clean') {
241 const dry = short.has('n') || long.has('--dry-run')
242 const forced = short.has('f') || long.has('--force')
243 const dirs = short.has('d')
244 if (forced && dirs && !dry)
245 return { what: '`git clean -fd`', why: 'it permanently deletes untracked files and directories (not recoverable from git)' }
246 return null
247 }
248 if (sub === 'branch') {
249 const forceDelete = short.has('D') || ((short.has('d') || long.has('--delete')) && (short.has('f') || long.has('--force')))
250 const hit = operands.find(o => /^(origin\/)?(main|master)$/.test(o))
251 if (forceDelete && hit) return { what: `\`git branch -D ${hit}\``, why: 'it force-deletes the main branch, unmerged commits included' }
252 return null
253 }
254 return null
255}
256
257function classifySegment(seg: Segment, all: Segment[], index: number, ctx: CommandContext, depth: number): Verdict | null {
258 const words = stripPrefixes(seg.words)
259 if (words.length === 0) return null
260 const cmd = basename(words[0] as string)
261 const args = words.slice(1)
262
263 // `bash -c '...'`, `sh -c`, `eval ...`: judge the inner script too.
264 if (depth < 3 && (SHELLS.has(cmd) || cmd === 'eval')) {
265 const ci = args.indexOf('-c')
266 const inner = cmd === 'eval' ? args.join(' ') : ci >= 0 ? args[ci + 1] : undefined
267 if (inner) {
268 const v = classifyCommandAt(inner, ctx, depth + 1)
269 if (v) return v
270 }
271 }
272
273 // A shell fed by a download: `curl ... | sh`.
274 if (SHELLS.has(cmd) && index > 0 && all[index - 1]?.op === '|') {
275 for (let j = index - 1; j >= 0 && (j === index - 1 || all[j]?.op === '|'); j--) {
276 const prev = stripPrefixes(all[j]?.words ?? [])
277 const name = basename(prev[0] ?? '')
278 if (name === 'curl' || name === 'wget')
279 return { what: `\`${name} … | ${cmd}\``, why: 'it runs a script straight from the internet without you reading it' }
280 if (j === 0 || all[j - 1]?.op !== '|') break
281 }
282 }
283
284 switch (cmd) {
285 case 'git':
286 return git(args, ctx)
287 case 'rm': {
288 const { short, long, operands } = flagsOf(args)
289 const recursive = short.has('r') || short.has('R') || long.has('--recursive')
290 const force = short.has('f') || long.has('--force')
291 if (!recursive || !force) return null
292 for (const t of operands) {
293 const why = rmTargetDanger(t, ctx)
294 if (why) return { what: `\`rm -rf ${t}\``, why }
295 }
296 return null
297 }
298 case 'terraform':
299 case 'tofu':
300 case 'terragrunt': {
301 if (args.includes('destroy') || (args.includes('apply') && args.includes('-destroy')))
302 return { what: `\`${cmd} destroy\``, why: 'it tears down real infrastructure' }
303 return null
304 }
305 case 'kubectl':
306 case 'oc': {
307 const di = args.indexOf('delete')
308 if (di < 0) return null
309 const kind = args.slice(di + 1).find(a => !a.startsWith('-'))
310 if (kind && /^(ns|namespace|namespaces)(\/|$)/.test(kind))
311 return { what: '`kubectl delete namespace`', why: 'it deletes every resource in the namespace' }
312 return null
313 }
314 case 'chmod': {
315 const { short, long, operands } = flagsOf(args)
316 if ((short.has('R') || long.has('--recursive')) && /^0?777$|^(a|ugo)[+=]rwx$/.test(operands[0] ?? ''))
317 return { what: '`chmod -R 777`', why: 'it makes a whole tree world-writable' }
318 return null
319 }
320 case 'dd': {
321 const of = args.find(a => a.startsWith('of='))?.slice(3)
322 if (of && of.startsWith('/dev/') && !SAFE_DD_TARGETS.test(of))
323 return { what: `\`dd of=${of}\``, why: 'it overwrites a raw device' }
324 return null
325 }
326 }
327 if (cmd === 'mkfs' || cmd.startsWith('mkfs.') || cmd === 'mke2fs' || cmd === 'wipefs')
328 return { what: `\`${cmd}\``, why: 'it formats a disk' }
329
330 return null
331}
332
333function classifyCommandAt(command: string, ctx: CommandContext, depth: number): Verdict | null {
334 const segments = splitCommand(command)
335 for (let i = 0; i < segments.length; i++) {
336 const v = classifySegment(segments[i] as Segment, segments, i, ctx, depth)
337 if (v) return v
338 }
339
340 // Process substitution / command substitution feeding a shell.
341 const m = /\b(?:ba|z|da|k)?sh\s+(?:-\w+\s+)*(?:<\(|-c\s+["']?\$\()\s*(curl|wget)\b/.exec(command)
342 if (m) return { what: `\`${m[1]} … | sh\``, why: 'it runs a script straight from the internet without you reading it' }
343
344 // Destructive SQL handed to a database client (inline, via -c/-e, a heredoc or a pipe).
345 const usesClient = segments.some(s => SQL_CLIENTS.has(basename(stripPrefixes(s.words)[0] ?? '')))
346 const sql = SQL_DESTRUCTIVE.exec(command)
347 if (usesClient && sql) {
348 const what = (sql[1] as string).replace(/\s+/g, ' ').toUpperCase()
349 return { what: `\`${what}\``, why: 'it permanently deletes database data' }
350 }
351 return null
352}
353
354/** Returns why a shell command is destructive, or null when it may run. */
355export function classifyCommand(command: string, ctx: CommandContext): Verdict | null {
356 return classifyCommandAt(command, ctx, 0)
357}
358
359// ---------------------------------------------------------------- files
360
361const ENV_OK = /^\.env\.(example|sample|template|dist|defaults)$/i
362
363/** Converts a simple glob (`*`, `**`, `?`) to an anchored regex. */
364export function globToRegExp(glob: string): RegExp {
365 let re = ''
366 for (let i = 0; i < glob.length; i++) {
367 const c = glob[i] as string
368 if (c === '*') {
369 if (glob[i + 1] === '*') {
370 re += '.*'
371 i++
372 if (glob[i + 1] === '/') i++
373 } else re += '[^/]*'
374 } else if (c === '?') re += '[^/]'
375 else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
376 }
377 return new RegExp(`^${re}$`)
378}
379
380export function parseExtra(extra: string | undefined): string[] {
381 return (extra ?? '')
382 .split(',')
383 .map(s => s.trim())
384 .filter(Boolean)
385}
386
387/** Why writing to `path` is refused, or null when it is an ordinary file. */
388export function protectedFile(path: string, extra: string[] = []): Verdict | null {
389 const p = path.replace(/\\/g, '/')
390 const parts = p.split('/').filter(Boolean)
391 const base = parts[parts.length - 1] ?? ''
392 const lower = base.toLowerCase()
393 const hit = (why: string): Verdict => ({ what: `a write to ${base}`, why })
394
395 const gi = parts.indexOf('.git')
396 if (gi >= 0 && gi < parts.length - 1) return { what: `a write inside .git/`, why: 'editing git internals can corrupt the repository' }
397
398 if (lower === '.env' || (lower.startsWith('.env.') && !ENV_OK.test(lower)))
399 return hit('.env files hold secrets; edit .env.example instead')
400 if (/\.(pem|key|p12|pfx)$/.test(lower)) return hit('it is a private key or certificate bundle')
401 if (/^id_(rsa|ed25519|ecdsa|dsa)/.test(lower)) return hit('it is an SSH key')
402 if (lower.startsWith('secrets.')) return hit('it is a secrets file')
403 if (/^\.?credentials(\.(json|ya?ml|toml|ini|xml|txt|env|csv|cfg|conf))?$/.test(lower)) return hit('it is a credentials file')
404 if (lower === '.npmrc' || lower === '.pypirc') return hit('it holds registry auth tokens')
405
406 for (const glob of extra) {
407 const re = globToRegExp(glob)
408 const matches = glob.includes('/') ? re.test(p) || parts.some((_, i) => re.test(parts.slice(i).join('/'))) : re.test(base)
409 if (matches) return hit(`it matches your extraProtected pattern "${glob}"`)
410 }
411 return null
412}
413
414export function denyText(v: Verdict): string {
415 return `safety-net: blocked ${v.what} — ${v.why}. Run it yourself if you really mean it, or /safety-net off for this session.`
416}
417types/index.d.ts 21 lines1export type Block = {
2 /** When it was blocked, ms since epoch. */
3 at: number
4 tool: string
5 what: string
6 why: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'safety-net': {
12 /** False after /safety-net off, for the rest of the session. */
13 enabled: boolean
14 /** Blocks this session. */
15 blocked: number
16 /** The most recent blocks, newest last. */
17 recent: Block[]
18 }
19 }
20}
21