Blocks destructive shell commands and access to secret files before they run, anywhere in a compound command

Blocks destructive shell commands and access to secret files before they run.
Settings permissions.deny rules match command prefixes, so cd app && rm -rf ~, rm -fr /, rm -r -f / or git push origin main -f get past them. guard sees every tool call inside Claude Code, splits compound commands, normalizes flags and wrappers (sudo, env, VAR=1), and resolves symlinks before it lets a file through.
/plugin install guard@claude-mods
Destructive commands (Bash), anywhere in a compound command:
| Rule | Blocks | Lets through | ||
|---|---|---|---|---|
rm-rf | rm recursive + force on /, ~, $HOME, ., .., *, /usr, /home/me, C:/, a bare $VAR/ | rm -rf node_modules, rm -rf ./dist, rm -rf "${OUT:?}/" | ||
git-force-push | --force, -f, -fu, +refspec | --force-with-lease | ||
git-reset-hard | git reset --hard | --soft, --mixed | ||
git-clean | git clean -f… | git clean -n | ||
chmod-777 | chmod -R 777 | chmod 755 file | ||
disk-write | mkfs*, dd of=/dev/… | dd of=./disk.img | ||
pipe-to-shell | `curl … \ | sh, wget … \ | sudo bash` | curl -o install.sh … |
fork-bomb | `:(){ :\ | :& };:` |
Secret files: .env, .env.*, *.pem, *.key, *.p12, *.pfx, id_rsa, id_ed25519 and friends, .npmrc, .pypirc, .netrc, .git-credentials, .aws/credentials, .docker/config.json, except .env.example, .env.sample, .env.template, .env.dist and *.pub.
They are blocked for Read, Edit, Write, NotebookEdit and Grep, on the path as given and on where it resolves (a symlink to .env is .env), and in shell commands that read, send or write them: cat .env, grep KEY .env, curl -d @.env, echo X >> .env, cp .env /tmp, git add .env. Commands that only name them pass: cp .env.example .env, echo .env >> .gitignore, source .env.
The model gets the rule and what to do instead, e.g.:
guard blocked this (git-force-push): it force-pushes and can overwrite commits on the remote. Use --force-with-lease, or ask the user.
/guard/guard lists the active rules and what was blocked this session/guard off pauses it for the session, /guard on resumes it. Only accepted from the person (the prompt, Remote Control, the SDK host), never from another agent, a peer session or a channel messageThe status line shows guard: N blocked (or guard: paused).
Set them in /config or under pluginConfigs.guard.options in settings:
| Option | Default | |
|---|---|---|
destructive | true | Block destructive commands |
rmMode | dangerous | any blocks every rm -rf |
secrets | true | Protect secret files |
blockCommands | [] | Extra regular expressions tested against every Bash command, e.g. \bnpm publish\b |
protectPaths | [] | Extra gitignore-like globs no tool may read or write, e.g. migrations/** |
allowPaths | [] | Globs exempt from the file rules, e.g. test/fixtures/** |
guard is a safety net against accidents, not a sandbox. The command check is a heuristic, not a shell parser: eval, scripts the model writes and then runs, interpreters (python -c, node -e) and the PowerShell tool are not inspected. Use Claude Code's sandbox and permission modes for containment.
hooks/register.ts 131 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GuardBlock } from '../types'
5import { buildPolicy, checkCommand, checkPath, denyMessage, describePolicy } from './rules'
6import type { Policy, Verdict } from './rules'
7
8const isPaused = atom({ plugin: 'guard', key: 'isPaused' } as const, false)
9const blocks = atom({ plugin: 'guard', key: 'blocks' } as const, [])
10
11/** Who may pause the guard: the person, never another agent, a channel or a peer session. */
12const PERSON = new Set(['composer', 'bridge', 'sdk'])
13
14const MAX_BLOCKS = 50
15
16async function showStatus($: EngineInterface) {
17 const count = (await read($, blocks)).length
18 const paused = await read($, isPaused)
19 $.ui.status(paused ? 'guard: paused' : count > 0 ? `guard: ${count} blocked` : undefined)
20}
21
22async function deny($: EngineInterface, tool: string, verdict: Verdict) {
23 const block: GuardBlock = { tool, rule: verdict.rule, subject: verdict.subject, at: await $.clock.now() }
24 await update($, blocks, list => [...list, block].slice(-MAX_BLOCKS))
25 await showStatus($)
26
27 return { deny: denyMessage(verdict) }
28}
29
30// The path as given, then where it really leads: a symlink to a secret file is
31// a secret file. A path that does not exist yet has no realPath.
32async function checkFile($: EngineInterface, path: string | undefined, policy: Policy) {
33 if (path === undefined || path === '') return undefined
34 const spelled = checkPath(path, policy)
35 if (spelled) return spelled
36
37 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
38 const real = stat?.realPath
39 const resolved = real !== undefined && real !== path ? checkPath(real, policy) : undefined
40
41 return resolved ? { ...resolved, subject: `${path} → ${real}` } : undefined
42}
43
44export const register: Register = (on, options) => {
45 const { policy, problems } = buildPolicy(options)
46
47 on('session.start', async ($, e, next) => {
48 await $.command.register({
49 name: 'guard',
50 description: 'Show the guard rules and what it blocked; /guard off or /guard on pauses it for this session',
51 argumentHint: '[on|off]',
52 })
53 if (problems.length > 0) $.ui.toast(`guard: ${problems.join('; ')}`)
54
55 return next(e)
56 })
57
58 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
59 if (await read($, isPaused)) return next(e)
60 const verdict = checkCommand(e.command, policy)
61
62 return verdict ? deny($, e.tool, verdict) : next(e)
63 })
64
65 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
66 if (await read($, isPaused)) return next(e)
67 const verdict = await checkFile($, e.file_path, policy)
68
69 return verdict ? deny($, e.tool, verdict) : next(e)
70 })
71
72 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
73 if (await read($, isPaused)) return next(e)
74 const verdict = await checkFile($, e.file_path, policy)
75
76 return verdict ? deny($, e.tool, verdict) : next(e)
77 })
78
79 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
80 if (await read($, isPaused)) return next(e)
81 const verdict = await checkFile($, e.file_path, policy)
82
83 return verdict ? deny($, e.tool, verdict) : next(e)
84 })
85
86 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
87 if (await read($, isPaused)) return next(e)
88 const verdict = await checkFile($, e.notebook_path, policy)
89
90 return verdict ? deny($, e.tool, verdict) : next(e)
91 })
92
93 // Grep is a built-in tool on some builds only (others search through Bash),
94 // so it is matched by name rather than by this build's tool types.
95 on('tool.call', async ($, e, next) => {
96 if (String(e.tool) !== 'Grep' || (await read($, isPaused))) return next(e)
97 const { path } = e as { path?: unknown }
98 const verdict = typeof path === 'string' ? await checkFile($, path, policy) : undefined
99
100 return verdict ? deny($, e.tool, verdict) : next(e)
101 })
102
103 on('command.run', { command: 'guard' }, async ($, e) => {
104 const arg = e.args.trim().toLowerCase()
105
106 if (arg === 'off' || arg === 'on') {
107 if (!PERSON.has(e.origin.kind)) {
108 return { text: `/guard ${arg} is only accepted from the person at the prompt (got ${e.origin.kind}).` }
109 }
110 await update($, isPaused, () => arg === 'off')
111 await showStatus($)
112
113 return {
114 text: arg === 'off' ? 'Paused for this session. /guard on resumes it.' : 'Active.',
115 }
116 }
117 if (arg !== '' && arg !== 'status') return { text: 'Usage: /guard [on|off]' }
118
119 const list = await read($, blocks)
120 const paused = await read($, isPaused)
121 const lines = [
122 paused ? 'Paused for this session (/guard on resumes it).' : 'Active.',
123 ...describePolicy(policy).map(line => ` ${line}`),
124 list.length === 0 ? 'Nothing blocked this session.' : `Blocked this session (${list.length}):`,
125 ...list.slice(-10).map(block => ` ${block.tool} · ${block.rule} · ${block.subject.slice(0, 80)}`),
126 ]
127
128 return { text: lines.join('\n') }
129 })
130}
131hooks/rules.ts 389 lines1// The guard's rules as pure functions: no `$`, so tests call them directly.
2
3export type Verdict = {
4 /** The rule that fired, as /guard lists it. */
5 rule: string
6 /** What was matched: a command segment or a path. */
7 subject: string
8 /** Why it is blocked, for the model and the person. */
9 reason: string
10 /** What to do instead. */
11 hint: string
12}
13
14export type RmMode = 'dangerous' | 'any'
15
16export type Policy = {
17 destructive: boolean
18 rmMode: RmMode
19 commands: { source: string; pattern: RegExp }[]
20 secrets: boolean
21 protect: { source: string; pattern: RegExp }[]
22 allow: RegExp[]
23}
24
25export const SECRET_GLOBS = [
26 '.env',
27 '.env.*',
28 '*.pem',
29 '*.key',
30 '*.p12',
31 '*.pfx',
32 'id_rsa',
33 'id_dsa',
34 'id_ecdsa',
35 'id_ed25519',
36 '.npmrc',
37 '.pypirc',
38 '.netrc',
39 '.git-credentials',
40 '.aws/credentials',
41 '.docker/config.json',
42]
43
44export const SECRET_EXCEPTIONS = ['.env.example', '.env.sample', '.env.template', '.env.dist', '*.pub']
45
46const SECRETS = SECRET_GLOBS.map(glob => globToRegExp(glob))
47const EXCEPTIONS = SECRET_EXCEPTIONS.map(glob => globToRegExp(glob))
48
49/**
50 * A gitignore-like glob: `*` and `?` stay within one path segment, `**`
51 * crosses them, and a pattern matches the end of the path at a segment
52 * boundary, so `.env` matches `/repo/.env` and `.aws/credentials` matches
53 * `~/.aws/credentials`. Case-insensitive, since some file systems are.
54 */
55export function globToRegExp(glob: string): RegExp {
56 const g = glob.replace(/\\/g, '/').replace(/^\.\//, '')
57 let source = ''
58
59 for (let i = 0; i < g.length; i++) {
60 const char = g[i]!
61 if (char === '*' && g[i + 1] === '*') {
62 i++
63 if (g[i + 1] === '/') {
64 i++
65 source += '(?:.*/)?'
66 } else {
67 source += '.*'
68 }
69 } else if (char === '*') {
70 source += '[^/]*'
71 } else if (char === '?') {
72 source += '[^/]'
73 } else {
74 source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
75 }
76 }
77
78 return new RegExp(`(?:^|/)${source}$`, 'i')
79}
80
81const normalize = (path: string) => path.replace(/\\/g, '/')
82
83const asList = (value: unknown): string[] =>
84 Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '') : []
85
86/** Builds the policy from the mod's options; bad patterns are reported, not thrown. */
87export function buildPolicy(options: Readonly<Record<string, unknown>>): { policy: Policy; problems: string[] } {
88 const problems: string[] = []
89 const commands: Policy['commands'] = []
90
91 for (const source of asList(options.blockCommands)) {
92 try {
93 commands.push({ source, pattern: new RegExp(source) })
94 } catch {
95 problems.push(`invalid blockCommands pattern /${source}/`)
96 }
97 }
98
99 return {
100 policy: {
101 destructive: options.destructive !== false,
102 rmMode: options.rmMode === 'any' ? 'any' : 'dangerous',
103 commands,
104 secrets: options.secrets !== false,
105 protect: asList(options.protectPaths).map(source => ({ source, pattern: globToRegExp(source) })),
106 allow: asList(options.allowPaths).map(source => globToRegExp(source)),
107 },
108 problems,
109 }
110}
111
112/** Checks one path (as given, or resolved) against the secret files and the protected paths. */
113export function checkPath(path: string, policy: Policy): Verdict | undefined {
114 const p = normalize(path)
115 if (policy.allow.some(pattern => pattern.test(p))) return undefined
116
117 if (policy.secrets && SECRETS.some(pattern => pattern.test(p)) && !EXCEPTIONS.some(pattern => pattern.test(p))) {
118 return {
119 rule: 'secret-file',
120 subject: path,
121 reason: 'it is a secret file (credentials, keys, environment)',
122 hint: 'Work from an example file such as .env.example, or ask the user for the value you need.',
123 }
124 }
125
126 const protectedBy = policy.protect.find(({ pattern }) => pattern.test(p))
127 if (protectedBy) {
128 return {
129 rule: 'protected-path',
130 subject: path,
131 reason: `it matches the protected path ${protectedBy.source}`,
132 hint: 'Leave this file alone, or ask the user to change it.',
133 }
134 }
135
136 return undefined
137}
138
139// ---------------------------------------------------------------------------
140// Shell commands
141
142const PREFIXES = new Set(['sudo', 'doas', 'command', 'builtin', 'exec', 'nohup', 'time', 'nice', 'ionice', 'xargs'])
143const OPTION_WITH_VALUE = new Set(['-u', '-g', '-n', '-c', '-C', '-p', '-I', '-L', '-P'])
144
145/** Programs that read, copy, send or edit the files they are given. */
146const FILE_TOUCHERS = new Set([
147 'cat', 'tac', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'xxd', 'od', 'hexdump', 'strings', 'base64',
148 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'awk', 'sed', 'cut', 'sort', 'uniq', 'diff', 'jq', 'yq',
149 'cp', 'mv', 'ln', 'scp', 'rsync', 'tee', 'curl', 'wget', 'nc', 'zip', 'tar', 'gzip',
150 'nano', 'vi', 'vim', 'nvim', 'emacs', 'code', 'open', 'type', 'truncate', 'shred',
151])
152
153/** Programs whose last argument is a destination: writing a secret file from a template is fine. */
154const COPIERS = new Set(['cp', 'mv', 'ln', 'scp', 'rsync'])
155
156const unquote = (token: string) => token.replace(/^(['"])(.*)\1$/, '$2')
157
158/**
159 * Splits a command line into simple commands (on `;`, `&&`, `||`, `|`, `&`,
160 * newlines, `$(`, backticks and parentheses) and each into words. Quoting is
161 * honoured for grouping only: this is a heuristic, not a shell parser.
162 */
163export function splitCommand(command: string): string[][] {
164 return command
165 .split(/\|\||&&|[;&|\n()`]|\$\(/)
166 .map(part => (part.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(unquote))
167 .filter(words => words.length > 0)
168}
169
170/** Drops wrappers (`sudo -u x`, `env A=1`, `VAR=1`, `nohup`) to reach the program and its arguments. */
171export function stripPrefixes(words: string[]): string[] {
172 const rest = [...words]
173
174 for (;;) {
175 const first = rest[0]
176 if (first === undefined) return rest
177 const name = basename(first)
178
179 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
180 rest.shift()
181 } else if (name === 'env') {
182 rest.shift()
183 while (rest[0] !== undefined && (rest[0].startsWith('-') || rest[0].includes('='))) rest.shift()
184 } else if (PREFIXES.has(name)) {
185 rest.shift()
186 while (rest[0]?.startsWith('-')) {
187 const option = rest.shift()!
188 if (OPTION_WITH_VALUE.has(option)) rest.shift()
189 }
190 } else {
191 return rest
192 }
193 }
194}
195
196const basename = (word: string) => normalize(word).split('/').pop() ?? word
197
198const shortFlags = (args: string[]) => args.filter(arg => /^-[^-]/.test(arg)).join('')
199
200const hasFlag = (args: string[], short: string, long: string) =>
201 args.includes(long) || shortFlags(args).includes(short)
202
203/** A target an `rm -rf` must not reach: a root, a home, the whole project, an unset variable. */
204export function isDangerousTarget(target: string): boolean {
205 const t = normalize(target)
206 const p = t.length > 1 ? t.replace(/\/+$/, '') : t
207
208 return (
209 /^(\/|\/\*|~|~\/\*|\$\{?HOME\}?(\/\*)?)$/.test(p) ||
210 /^(\.|\.\/\*|\*|\.\*|\.\.|\.\.\/\*)$/.test(p) ||
211 /^\/[^/]+(\/[^/]+)?(\/\*)?$/.test(p) ||
212 /^(~|\$\{?HOME\}?)\/[^/]+$/.test(p) ||
213 /^[A-Za-z]:(\/[^/]*)?$/.test(p) ||
214 // `$DIR/` or `"$DIR"/*` with DIR unset is `/`; `${DIR:?}` refuses to expand empty
215 (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(\/\*?)?$/.test(p) && !p.includes(':?'))
216 )
217}
218
219const deny = (rule: string, words: string[], reason: string, hint: string): Verdict => ({
220 rule,
221 subject: words.join(' '),
222 reason,
223 hint,
224})
225
226function checkDestructive(words: string[], rmMode: RmMode): Verdict | undefined {
227 const [program = '', ...args] = words
228 const name = basename(program)
229
230 if (name === 'rm') {
231 const end = args.indexOf('--')
232 const flags = (end < 0 ? args : args.slice(0, end)).filter(arg => arg.startsWith('-'))
233 const targets = args.filter((arg, i) => !(arg.startsWith('-') && (end < 0 || i < end)) && arg !== '--')
234 const isRecursive = flags.includes('--recursive') || /[rR]/.test(shortFlags(flags))
235 const isForced = flags.includes('--force') || shortFlags(flags).includes('f')
236
237 if (isRecursive && isForced) {
238 if (rmMode === 'any') {
239 return deny('rm-rf', words, 'it force-deletes recursively', 'Delete specific files, or ask the user to run it.')
240 }
241 const target = targets.find(isDangerousTarget)
242 if (target !== undefined) {
243 return deny(
244 'rm-rf',
245 words,
246 `it force-deletes ${target} recursively`,
247 'Name a specific path inside the project, guard variables with ${VAR:?}, or ask the user to run it.',
248 )
249 }
250 }
251 }
252
253 if (name === 'git') {
254 let i = 0
255 while (i < args.length && args[i]!.startsWith('-')) i += args[i] === '-C' || args[i] === '-c' ? 2 : 1
256 const sub = args[i]
257 const rest = args.slice(i + 1)
258
259 if (sub === 'push') {
260 const isForced =
261 rest.includes('--force') || shortFlags(rest).includes('f') || rest.some(arg => /^\+[^+]/.test(arg))
262 if (isForced) {
263 return deny(
264 'git-force-push',
265 words,
266 'it force-pushes and can overwrite commits on the remote',
267 'Use --force-with-lease, or ask the user.',
268 )
269 }
270 }
271 if (sub === 'reset' && rest.includes('--hard')) {
272 return deny(
273 'git-reset-hard',
274 words,
275 'it discards uncommitted changes for good',
276 'Use git stash, or ask the user.',
277 )
278 }
279 if (sub === 'clean' && hasFlag(rest, 'f', '--force') && !hasFlag(rest, 'n', '--dry-run')) {
280 return deny(
281 'git-clean',
282 words,
283 'it deletes untracked files for good',
284 'Preview with git clean -n and ask the user.',
285 )
286 }
287 }
288
289 if (name === 'chmod' && hasFlag(args, 'R', '--recursive') && args.some(arg => /^(0?777|a\+rwx|ugo\+rwx)$/.test(arg))) {
290 return deny('chmod-777', words, 'it makes a whole tree world-writable', 'Grant the narrowest permission needed.')
291 }
292
293 if (name.startsWith('mkfs') || (name === 'dd' && args.some(arg => /^of=\/dev\//.test(arg)))) {
294 return deny('disk-write', words, 'it writes a file system or raw data to a device', 'Ask the user to run it.')
295 }
296
297 return undefined
298}
299
300function checkWhole(command: string): Verdict | undefined {
301 const piped = command.match(/\b(curl|wget)\b[^\n;&|]*\|\s*(sudo\s+)?(env\s+(\S+=\S*\s+)*)?(ba|z|da|k|fi)?sh\b/)
302 if (piped) {
303 return {
304 rule: 'pipe-to-shell',
305 subject: piped[0],
306 reason: 'it runs a downloaded script without review',
307 hint: 'Download the script to a file, show it to the user, and run it only after they agree.',
308 }
309 }
310 if (/:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(command)) {
311 return { rule: 'fork-bomb', subject: ':(){ :|:& };:', reason: 'it is a fork bomb', hint: 'Do not run it.' }
312 }
313
314 return undefined
315}
316
317function checkSecretMentions(words: string[], policy: Policy): Verdict | undefined {
318 const [program = '', ...args] = words
319 const name = basename(program)
320 const isGitAdd = name === 'git' && args.includes('add')
321 const destination = COPIERS.has(name) ? args.findLastIndex(arg => !arg.startsWith('-')) : -1
322
323 for (let i = 0; i < args.length; i++) {
324 if (i === destination) continue
325 const arg = args[i]!
326 const isRedirect = /^\d*(>>?|<)&?$/.test(arg)
327 const target = isRedirect ? args[i + 1] : arg.replace(/^\d*(>>?|<)&?/, '')
328
329 if (!isRedirect && !/^\d*(>>?|<)/.test(arg) && !FILE_TOUCHERS.has(name) && !isGitAdd) continue
330 if (target === undefined || target === '' || target.startsWith('-')) continue
331
332 const verdict = checkPath(target.replace(/^@/, '').replace(/^[A-Za-z_]+=/, ''), policy)
333 if (verdict) return { ...verdict, subject: words.join(' ') }
334 }
335
336 return undefined
337}
338
339/** Checks a whole Bash command line; the first rule that fires wins. */
340export function checkCommand(command: string, policy: Policy): Verdict | undefined {
341 for (const { source, pattern } of policy.commands) {
342 if (pattern.test(command)) {
343 return {
344 rule: 'blocked-command',
345 subject: command,
346 reason: `it matches the blocked pattern /${source}/`,
347 hint: 'Find another way, or ask the user to run it.',
348 }
349 }
350 }
351
352 if (policy.destructive) {
353 const whole = checkWhole(command)
354 if (whole) return whole
355 }
356
357 for (const words of splitCommand(command).map(stripPrefixes)) {
358 if (words.length === 0) continue
359 const verdict =
360 (policy.destructive ? checkDestructive(words, policy.rmMode) : undefined) ??
361 (policy.secrets || policy.protect.length > 0 ? checkSecretMentions(words, policy) : undefined)
362 if (verdict) return verdict
363 }
364
365 return undefined
366}
367
368/** The text the model receives in place of the tool's result. */
369export function denyMessage(verdict: Verdict): string {
370 return `guard blocked this (${verdict.rule}): ${verdict.reason}. ${verdict.hint}`
371}
372
373/** The active rules, one per line, for /guard. */
374export function describePolicy(policy: Policy): string[] {
375 const lines: string[] = []
376 if (policy.destructive) {
377 lines.push(
378 `destructive commands: rm -rf (${policy.rmMode === 'any' ? 'any target' : 'dangerous targets'}), git push --force, git reset --hard, git clean -f, chmod -R 777, mkfs/dd to devices, curl|sh`,
379 )
380 }
381 if (policy.secrets) lines.push(`secret files: ${SECRET_GLOBS.join(', ')} (except ${SECRET_EXCEPTIONS.join(', ')})`)
382 if (policy.protect.length > 0) lines.push(`protected paths: ${policy.protect.map(({ source }) => source).join(', ')}`)
383 if (policy.commands.length > 0) lines.push(`blocked patterns: ${policy.commands.map(({ source }) => `/${source}/`).join(', ')}`)
384 if (policy.allow.length > 0) lines.push('allowed paths override the file rules')
385 if (lines.length === 0) lines.push('no rules are on')
386
387 return lines
388}
389types/index.d.ts 20 lines1export type GuardBlock = {
2 /** The tool whose call was denied. */
3 tool: string
4 /** The rule that fired. */
5 rule: string
6 /** The command segment or path it matched. */
7 subject: string
8 /** When, in ms since the epoch. */
9 at: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 guard: {
15 isPaused: boolean
16 blocks: GuardBlock[]
17 }
18 }
19}
20