SLOPSHOPPER

guard

Guardrails for Claude Code: warns on or blocks dangerous commands, secret access and self-config edits, with a log and /guard.

newpaneguardcommandtoaststatus
v0.4.0MITupdated 2026-10-20LordSantox/guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guard
│ ┃ Guard ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 🛡 modo avisar · 0 bloqueos · 2 avisos │ guard │ │ ┃ [ Avisar ][ Bloquear ][ Limpiar ] ⏺ Read(src/auth.ts) │ 🛡 guard: push forzado (reescribe historia │ │ ┃ ✓ sin cambios pendientes de revisión ⎿ Read 6 lines │ remota) (Bash) │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ Bitácora ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ ! aviso secret-read · Bash · 2:23:20 PM [ Pe ⏺ Bash(bun test) │ guard │ │ ┃ lee un archivo de secretos desde la shell: ⎿ 3 pass, 1 fail │ 🛡 guard: lee un archivo de secretos desde │ │ ┃ cat .env │ la shell (Bash) │ │ ┃ ! aviso force-push · Bash · 2:23:20 PM [ Per ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ push forzado (reescribe historia remota): │ ┃ rm -rf build && git push --force origin main ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Recomendado: Trail of Bits (2 faltan) › /guard │ ┃ /plugin install differential-review ⎿ guard: Panel guard abierto. │ ┃ --marketplace trailofbits/skills │ ┃ /plugin install supply-chain-risk-auditor │ ┃ --marketplace trailofbits/skills │ ┃ [ Ocultar ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ guard: 🛡 guard: 0 bloqueos · 2 avisos

Draws

Pane · Guard
🛡 modo avisar · 0 bloqueos · 2 avisos [ Avisar ][ Bloquear ][ Limpiar ] ✓ sin cambios pendientes de revisión Bitácora ! aviso secret-read · Bash · 2:23:20 PM [ Permitir regla ] lee un archivo de secretos desde la shell: cat .env ! aviso force-push · Bash · 2:23:20 PM [ Permitir regla ] push forzado (reescribe historia remota): rm -rf build && git push --force origin main Recomendado: Trail of Bits (2 faltan) /plugin install differential-review --marketplace trailofbits/skills /plugin install supply-chain-risk-auditor --marketplace trailofbits/skills [ Ocultar ]
README

guard

English | Español

A Claude Code mod that puts guardrails on every tool call: it warns about or blocks dangerous commands, secret access and edits to Claude's own config. Local regex rules, zero model calls, with a log and a /guard command.

Install

In a Claude Code session:

/plugin install guard --marketplace LordSantox/guard

Answer y to add the marketplace, then choose the scope (user or project).

From a terminal:

claude plugin install guard --marketplace LordSantox/guard

Install by asking Claude

Install the mod from https://github.com/LordSantox/guard following the instructions in its README.

How it works

Default mode is warn: high rules let the action run, show a toast and add a note telling Claude to confirm the user asked for it. critical rules always block.

RuleSeverityCatches
rm-root, ps-rm-rootcriticalrecursive delete of /, ~, $HOME, *, C:\
disk-wipe, fork-bombcriticalmkfs, dd of=/dev/…, format C:, fork bomb
pipe-shellcritical`curl … \sh, iwr … \iex`
exfilcriticalcurl -d @file, `env \curl`, uploads
self-configcriticalClaude editing .claude/settings*.json or this mod
force-push, git-destroyhighgit push --force, reset --hard, clean -fdx
sql-drop, chmod-777highDROP TABLE, TRUNCATE, chmod 777
opaquehigh`base64 -d \sh, -EncodedCommand, eval "$…"`
remote-pkghighnpm i https://…, pip install git+…
secret-file, secret-readhigh.env, id_rsa, ~/.ssh, ~/.aws, *.pem, .npmrc
secret-in-codehighwriting tokens like ghp_…, sk-…, AKIA…, private keys

It normalizes before matching, so r''m, \rm, X=rm; $X -rf /, bash -c "…" and a && b chains are caught. rm -rf node_modules, .env.example and --force-with-lease are allowed.

Review before commit (with Trail of Bits)

guard does not reinvent auditing: on session start it checks whether these are installed and, if not, shows a toast and the install lines in /guard. It relies on trailofbits/skills. Install the recommended ones:

/plugin install differential-review --marketplace trailofbits/skills
/plugin install supply-chain-risk-auditor --marketplace trailofbits/skills
  • After Claude edits files, a git commit/push without a security review triggers rule no-review (high): warns in warn mode, blocks in block mode. guard ships its own short skill, guard-review (diff only, ≤10-line answer), which Claude is told to run; differential-review, /diff-review or /security-review also clear it.
  • On npm install <pkg>, pip install <pkg>, cargo add, go get, guard tells Claude to run supply-chain-risk-auditor.

Commands

/guard                    open the Guard panel (log, mode, allowed rules)
/guard log                text log (last 15)
/guard avisar             warn mode (default)
/guard bloquear           block mode: also blocks "high" rules
/guard permitir <rule>    disable a rule
/guard quitar <rule>      re-enable it
/guard limpiar            clear the log
/guard ocultar            stop the Trail of Bits install hint

The UI text is in Spanish.

Limits

This is a defense layer, not a sandbox. Regex can be evaded (scripts written then executed, python -c, unusual quoting). Use it together with Claude Code's permissions.

License

MIT

Source 2 files
hooks/register.tsx 182 lines
1import type { Register } from 'claude-code'
2
3import { blocks, check, installLines, isCommit, missingTob, isEdit, isPkgInstall, isReview, NO_REVIEW, type Hit, type Mode } from './rules'
4
5const MAX_LOG = 50
6const PANE = 'guard'
7type Entry = { at: number; tool: string; rule: string; sev: string; why: string; blocked: boolean; what: string }
8
9async function cfg($: any): Promise<{ mode: Mode; allowed: string[] }> {
10  return {
11    mode: ((await $.store.get('mode')) as Mode) ?? 'avisar',
12    allowed: ((await $.store.get('allowed')) as string[]) ?? [],
13  }
14}
15
16const save = async ($: any, k: string, v: unknown) => { await $.store.set(k, v); $.ui.invalidate('ui.render') }
17
18async function log($: any, entry: Entry) {
19  const list = ((await $.store.get('log')) as Entry[]) ?? []
20  await save($, 'log', [...list, entry].slice(-MAX_LOG))
21  const b = list.filter(x => x.blocked).length + (entry.blocked ? 1 : 0)
22  $.ui.status(`🛡 guard: ${b} bloqueos · ${list.length + 1 - b} avisos`)
23}
24
25const missing = async ($: any) => { try { return missingTob((await $.settings.read()).enabledPlugins) } catch { return [] } }
26
27const what = (e: any) => String(e.command ?? e.file_path ?? e.notebook_path ?? '').slice(0, 120)
28
29const HELP = [
30  '/guard                 abre el panel',
31  '/guard log             bitácora en texto (últimos 15)',
32  '/guard avisar          avisa y deja pasar lo "high" (por defecto)',
33  '/guard bloquear        bloquea también lo "high"',
34  '/guard permitir <regla>  desactiva una regla',
35  '/guard quitar <regla>    la vuelve a activar',
36  '/guard limpiar         borra la bitácora',
37  '/guard ocultar         no avisar más de las skills de Trail of Bits',
38].join('\n')
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    await $.command.register({ name: 'guard', description: 'Guard: bitácora, modo avisar/bloquear y reglas permitidas' })
43    const miss = await missing($)
44    if (miss.length && !(await $.store.get('hideTob'))) {
45      $.ui.toast(`🛡 guard: faltan skills de Trail of Bits (${miss.join(', ')}). Escribe /guard para ver cómo instalarlas.`)
46    }
47    return next(e)
48  })
49
50  on('command.run', { command: 'guard' }, async ($, e) => {
51    const [verb, arg] = e.args.trim().split(/\s+/)
52    const { mode, allowed } = await cfg($)
53    if (verb === 'avisar' || verb === 'bloquear') {
54      await save($, 'mode', verb)
55      return { text: `guard: modo ${verb}. Las reglas "critical" bloquean siempre.` }
56    }
57    if (verb === 'permitir' && arg) {
58      await save($, 'allowed', [...new Set([...allowed, arg])])
59      return { text: `guard: regla ${arg} permitida.` }
60    }
61    if (verb === 'quitar' && arg) {
62      await save($, 'allowed', allowed.filter(r => r !== arg))
63      return { text: `guard: regla ${arg} activa otra vez.` }
64    }
65    if (verb === 'limpiar') {
66      await save($, 'log', [])
67      $.ui.status(undefined)
68      return { text: 'guard: bitácora borrada.' }
69    }
70    if (verb === 'ocultar') {
71      await save($, 'hideTob', true)
72      return { text: 'guard: no avisaré más de Trail of Bits.' }
73    }
74    if (!verb) {
75      await $.ui.open({ id: PANE, title: 'Guard' })
76      return { text: 'Panel guard abierto.' }
77    }
78    if (verb !== 'log') return { text: HELP }
79    const list = ((await $.store.get('log')) as Entry[]) ?? []
80    const rows = list.slice(-15).reverse().map(x =>
81      `${new Date(x.at).toLocaleTimeString()} ${x.blocked ? 'BLOQUEO' : 'aviso  '} ${x.rule} (${x.sev}) ${x.tool}: ${x.what}`)
82    const miss = await missing($)
83    const tob = miss.length ? ['', `Recomendado: skills de Trail of Bits para revisar el código (faltan ${miss.length}):`, ...installLines(miss)] : []
84    return { text: [`guard · modo ${mode}${allowed.length ? ` · permitidas: ${allowed.join(', ')}` : ''}`, ...(rows.length ? rows : ['Sin eventos.']), ...tob, '', HELP].join('\n') }
85  })
86
87  // Revisión registrada al usar la skill o el comando de Trail of Bits (o /security-review).
88  on('command.run', async ($, e, next) => {
89    if (isReview(e.command)) await save($, 'pending', false)
90    return next(e)
91  })
92
93  on('tool.call', async ($, e, next) => {
94    const ev = e as any
95    const tool = String(ev.tool ?? '')
96    if (tool === 'Skill' && isReview(String(ev.skill ?? ''))) await save($, 'pending', false)
97    const cmd = tool === 'Bash' || tool === 'PowerShell' ? String(ev.command ?? '') : ''
98    let hit: Hit | undefined = check(tool, ev)
99    if (!hit && cmd && isCommit(cmd) && (await $.store.get('pending'))) hit = NO_REVIEW
100    if (!hit) {
101      const r = await next(e)
102      if (r.deny !== undefined) return r
103      if (isEdit(tool)) await save($, 'pending', true)
104      if (cmd && isCommit(cmd) && !r.isError) await save($, 'pending', false)
105      if (cmd && isPkgInstall(cmd)) {
106        return { ...r, context: [...(r.context ?? []), 'guard: dependencia nueva. Si está instalada, usa la skill supply-chain-risk-auditor (Trail of Bits) para evaluar su riesgo.'] }
107      }
108      return r
109    }
110    const { mode, allowed } = await cfg($)
111    if (allowed.includes(hit.rule)) return next(e)
112    const blocked = blocks(hit, mode, allowed)
113    await log($, { at: Date.now(), tool, rule: hit.rule, sev: hit.sev, why: hit.why, blocked, what: what(ev) })
114    const fix = hit.rule === 'no-review' ? ' Usa la skill guard-review (revisión corta del diff) y luego reintenta.' : ''
115    if (blocked) {
116      void $.ui.open({ id: PANE, title: 'Guard' })
117      $.ui.toast(`🛡 guard bloqueó ${tool}: ${hit.why}`)
118      return { deny: `guard: bloqueado (${hit.rule}: ${hit.why}). Si el usuario lo quiere de verdad, que lo ejecute él o use /guard permitir ${hit.rule}.${fix}` }
119    }
120    $.ui.toast(`🛡 guard: ${hit.why} (${tool})`)
121    const r = await next(e)
122    if (r.deny !== undefined) return r
123    if (cmd && isCommit(cmd) && !r.isError) await save($, 'pending', false)
124    return { ...r, context: [...(r.context ?? []), `guard: aviso ${hit.rule} (${hit.why}). ${fix || 'Confirma que el usuario lo pidió; si no, revierte.'}`] }
125  // Si el guard falla, deja pasar (no bloquea el trabajo por un bug propio); el fallo queda en el log de debug.
126  }).catch(($, e, next) => next(e))
127  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
128    const { Box, Text, Button } = $.ui.resolve(e)
129    const { mode, allowed } = await cfg($)
130    const list = ((await $.store.get('log')) as Entry[]) ?? []
131    const pending = Boolean(await $.store.get('pending'))
132    const miss = (await $.store.get('hideTob')) ? [] : await missing($)
133    const b = list.filter(x => x.blocked).length
134    return (
135      <Box flexDirection="column">
136        <Text bold>🛡 modo {mode} · {b} bloqueos · {list.length - b} avisos</Text>
137        <Box>
138          <Button key="m-avisar" variant={mode === 'avisar' ? 'primary' : undefined} onPress={() => void save($, 'mode', 'avisar')}>Avisar</Button>
139          <Button key="m-bloquear" variant={mode === 'bloquear' ? 'primary' : undefined} onPress={() => void save($, 'mode', 'bloquear')}>Bloquear</Button>
140          <Button key="clear" dimColor onPress={() => void save($, 'log', []).then(() => $.ui.status(undefined))}>Limpiar</Button>
141        </Box>
142        <Text color={pending ? 'yellow' : 'green'}>{pending ? '● cambios sin revisar: el próximo commit pedirá guard-review' : '✓ sin cambios pendientes de revisión'}</Text>
143        {allowed.length > 0 && (
144          <Box flexDirection="column" marginTop={1}>
145            <Text bold>Reglas permitidas</Text>
146            {allowed.map(r => (
147              <Box key={`a-${r}`}>
148                <Text>  {r} </Text>
149                <Button key={`q-${r}`} dimColor onPress={() => void save($, 'allowed', allowed.filter(x => x !== r))}>Reactivar</Button>
150              </Box>
151            ))}
152          </Box>
153        )}
154        <Box flexDirection="column" marginTop={1}>
155          <Text bold>Bitácora</Text>
156          {list.length === 0 && <Text dimColor>Sin eventos todavía.</Text>}
157          {list.slice(-20).reverse().map((x, i) => (
158            <Box key={`l-${x.at}-${i}`} flexDirection="column">
159              <Box>
160                <Text color={x.blocked ? 'red' : 'yellow'}>{x.blocked ? '✗ bloqueo' : '! aviso'} </Text>
161                <Text bold>{x.rule}</Text>
162                <Text dimColor> · {x.tool} · {new Date(x.at).toLocaleTimeString()} </Text>
163                {!allowed.includes(x.rule) && (
164                  <Button key={`p-${x.at}-${i}`} dimColor onPress={() => void save($, 'allowed', [...new Set([...allowed, x.rule])])}>Permitir regla</Button>
165                )}
166              </Box>
167              <Text dimColor>  {x.why}: {x.what}</Text>
168            </Box>
169          ))}
170        </Box>
171        {miss.length > 0 && (
172          <Box flexDirection="column" marginTop={1}>
173            <Text bold>Recomendado: Trail of Bits ({miss.length} faltan)</Text>
174            {installLines(miss).map(l => <Text key={l} color="cyan">  {l}</Text>)}
175            <Button key="hide" dimColor onPress={() => void save($, 'hideTob', true)}>Ocultar</Button>
176          </Box>
177        )}
178      </Box>
179    )
180  })
181}
182
hooks/rules.ts 123 lines
1// Reglas locales (regex) sobre cada llamada a herramienta. Cero llamadas al modelo.
2// No es un sandbox: es una capa que atrapa errores y evasiones comunes.
3
4export type Sev = 'critical' | 'high'
5export type Hit = { rule: string; sev: Sev; why: string }
6
7type Rule = { id: string; sev: Sev; why: string; re: RegExp }
8
9const HOME = String.raw`(~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)`
10
11// Se prueban sobre cada segmento ya normalizado (sin comillas ni escapes).
12const CMD: Rule[] = [
13  { id: 'rm-root', sev: 'critical', why: 'borrado recursivo de raíz, home o comodín',
14    re: new RegExp(String.raw`\brm\s+(-\w*[rR]\w*\s+|--recursive\s+)+(-\w+\s+)*(/|/\*|${HOME}/?\*?|\.\.?/?\*?|\*|[a-z]:[\\/]?)(\s|$)`, 'i') },
15  { id: 'ps-rm-root', sev: 'critical', why: 'Remove-Item recursivo sobre raíz o home',
16    re: new RegExp(String.raw`\bremove-item\b.*-recurse.*\s(/|[a-z]:[\\/]?|${HOME}[\\/]?)(\s|$)`, 'i') },
17  { id: 'disk-wipe', sev: 'critical', why: 'escritura directa a disco o formateo',
18    re: /\b(mkfs(\.\w+)?|dd\s+.*\bof=\/dev\/|format\s+[a-z]:)/i },
19  { id: 'fork-bomb', sev: 'critical', why: 'fork bomb', re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}/ },
20  { id: 'pipe-shell', sev: 'critical', why: 'descarga ejecutada directo en la shell',
21    re: /\b(curl|wget|iwr|invoke-webrequest|irm|invoke-restmethod)\b.*\|\s*(sudo\s+)?(ba|z|da)?sh\b|\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b.*\|\s*(iex|invoke-expression)\b/i },
22  { id: 'force-push', sev: 'high', why: 'push forzado (reescribe historia remota)',
23    re: /\bgit\s+push\b.*(\s--force(?!-with-lease)\b|\s-f\b|\s\+\S+)/i },
24  { id: 'git-destroy', sev: 'high', why: 'descarta cambios locales sin respaldo',
25    re: /\bgit\s+(reset\s+--hard|clean\s+-\w*[fdx]\w*[fdx]|checkout\s+--\s+\.)/i },
26  { id: 'sql-drop', sev: 'high', why: 'borra tablas o bases de datos', re: /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i },
27  { id: 'chmod-777', sev: 'high', why: 'permisos abiertos a todos', re: /\bchmod\s+(-R\s+)?0?777\b/i },
28  { id: 'exfil', sev: 'critical', why: 'envía un archivo o el entorno por red',
29    re: /\b(curl|wget)\b.*(\s-d\s*@|--data(-binary|-raw)?\s*@|-F\s*\S+=@|--upload-file|-T\s)|\b(env|printenv)\b.*\|\s*(curl|nc|wget)\b|\bnc\s+-\w*\s*\S+\s+\d+\s*<\s*\S/i },
30  { id: 'opaque', sev: 'high', why: 'comando codificado u opaco que no se puede revisar',
31    re: /\bbase64\s+(-d|--decode)\b.*\|\s*(ba|z)?sh\b|-enc(odedcommand)?\s+[A-Za-z0-9+/=]{16,}|\beval\s+["$`]/i },
32  { id: 'remote-pkg', sev: 'high', why: 'instala un paquete desde URL o tarball',
33    re: /\b(npm|pnpm|yarn)\s+(i|install|add)\b.*\s(https?:|git\+|\S+\.tgz)|\bpip3?\s+install\b.*\s(https?:|git\+)/i },
34]
35
36// Rutas de secretos: leer, editar o escribir.
37const SECRET_PATH = /(^|[\\/])(\.env(\.(?!example|sample|template)[\w.-]+)?|id_(rsa|ed25519|ecdsa|dsa)(\.pub)?|\.npmrc|\.pypirc|\.netrc|credentials|kubeconfig|[\w.-]+\.(pem|key|p12|pfx))$|[\\/]\.ssh[\\/]|[\\/]\.aws[\\/]/i
38// El propio guard y la configuración de Claude: el agente no se desactiva solo.
39const SELF_PATH = /[\\/]\.claude[\\/](settings(\.local)?\.json|mods[\\/]guard[\\/])/i
40// Tokens con forma conocida dentro de lo que se escribe.
41const TOKEN = /\b(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{40,}|sk-(ant-|proj-)?[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/
42
43const SHELL_READ = /\b(cat|type|get-content|gc|less|more|head|tail|cp|copy|scp|base64|xxd)\b/i
44
45// Quita comillas y escapes, para que r''m, "rm" y \rm cuenten como rm.
46export function normalize(s: string): string {
47  return s.replace(/\\\r?\n/g, ' ').replace(/(["'`])\1/g, '').replace(/(^|\s)\\(?=\w)/g, '$1').replace(/["']/g, '')
48}
49
50// Parte por separadores de shell y abre bash -c / sh -c / $( ) / backticks.
51export function segments(cmd: string, depth = 0): string[] {
52  if (depth > 3) return [cmd]
53  const out: string[] = [cmd]
54  const inner = [...cmd.matchAll(/\b(?:ba|z|da)?sh\s+-c\s+(["'])([\s\S]*?)\1|\$\(([^()]*)\)|`([^`]*)`|powershell(?:\.exe)?\s+(?:-\w+\s+)*-c(?:ommand)?\s+(["'])([\s\S]*?)\5/gi)]
55  for (const m of inner) {
56    const body = m[2] ?? m[3] ?? m[4] ?? m[6]
57    if (body) out.push(...segments(body, depth + 1))
58  }
59  // shortcut: no entiende comillas al partir, puede sobre-partir; basta para reglas por segmento.
60  out.push(...cmd.split(/\s*(?:;|&&|\|\||\r?\n)\s*/).filter(p => p && p !== cmd))
61  return out
62}
63
64// Variables triviales: X=rm; $X -rf / → rm -rf /
65function expandVars(cmd: string): string {
66  const vars = new Map<string, string>()
67  for (const m of cmd.matchAll(/(?:^|[;&\s])([A-Za-z_]\w*)=([^\s;&|]+)/g)) vars.set(m[1], m[2])
68  return vars.size ? cmd.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (all, k) => vars.get(k) ?? all) : cmd
69}
70
71export function checkCommand(raw: string): Hit | undefined {
72  const cmd = expandVars(normalize(raw))
73  for (const seg of segments(cmd)) {
74    for (const r of CMD) if (r.re.test(seg)) return { rule: r.id, sev: r.sev, why: r.why }
75    if (SHELL_READ.test(seg) && seg.split(/\s+/).some(w => SECRET_PATH.test(w))) {
76      return { rule: 'secret-read', sev: 'high', why: 'lee un archivo de secretos desde la shell' }
77    }
78  }
79  return undefined
80}
81
82export function checkPath(path: string, writing: boolean): Hit | undefined {
83  if (writing && SELF_PATH.test(path)) return { rule: 'self-config', sev: 'critical', why: 'edita la configuración de Claude o del propio guard' }
84  if (SECRET_PATH.test(path)) return { rule: 'secret-file', sev: 'high', why: 'toca un archivo de secretos' }
85  return undefined
86}
87
88export function checkContent(text: string): Hit | undefined {
89  return TOKEN.test(text) ? { rule: 'secret-in-code', sev: 'high', why: 'escribe algo con forma de clave o token' } : undefined
90}
91
92// Punto único: qué revisar según la herramienta.
93export function check(tool: string, input: any): Hit | undefined {
94  if (tool === 'Bash' || tool === 'PowerShell') return checkCommand(String(input?.command ?? ''))
95  const path = String(input?.file_path ?? input?.notebook_path ?? '')
96  if (tool === 'Read') return path ? checkPath(path, false) : undefined
97  if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
98    const text = [input?.content, input?.new_string, input?.new_source, ...(input?.edits ?? []).map((x: any) => x?.new_string)].filter(Boolean).join('\n')
99    return (path && checkPath(path, true)) || checkContent(text)
100  }
101  return undefined
102}
103
104// Revisión antes de commit: se apoya en las skills de Trail of Bits (trailofbits/skills).
105export const isCommit = (cmd: string) => /\bgit\s+(commit|push)\b/i.test(normalize(cmd))
106export const isEdit = (tool: string) => /^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)
107export const isReview = (name: string) => /guard-review|differential-review|diff-review|security-review/i.test(name)
108export const isPkgInstall = (cmd: string) =>
109  /\b(npm|pnpm|yarn|bun)\s+(i|install|add)\s+(?!-)\S|\bpip3?\s+install\s+(?!-r\b)(?!-e\b)\S|\bcargo\s+add\b|\bgo\s+get\b/i.test(normalize(cmd))
110export const TOB = ['differential-review', 'supply-chain-risk-auditor']
111// enabledPlugins usa claves "<plugin>@<marketplace>".
112export const missingTob = (enabled: unknown) => {
113  const keys = Object.entries((enabled ?? {}) as Record<string, unknown>).filter(([, on]) => on).map(([k]) => k.split('@')[0])
114  return TOB.filter(p => !keys.includes(p))
115}
116export const installLines = (names: string[]) => names.map(n => `/plugin install ${n} --marketplace trailofbits/skills`)
117export const NO_REVIEW: Hit = { rule: 'no-review', sev: 'high', why: 'commit/push sin revisión de seguridad del diff' }
118
119// critical bloquea siempre; high bloquea solo en modo 'bloquear'.
120export type Mode = 'avisar' | 'bloquear'
121export const blocks = (h: Hit, mode: Mode, allowed: readonly string[]) =>
122  !allowed.includes(h.rule) && (h.sev === 'critical' || mode === 'bloquear')
123