SLOPSHOPPER

skeleton-guard

The Mod Skeleton: counts tool calls, holds destructive shell commands and secret files, and shows a tally above the prompt

newbandspinnerguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skeleton-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by skeleton-guard: skeleton-guard: "rm -rf build && git push --force origin main" is destructive; a ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /skeleton ⎿ skeleton-guard: skeleton-guard: this session 8 tool calls, 1 held; all time 8 tool calls over 1 sessions. ✻ Thinking · tool calls: 8…… skeleton-guard · this session 8 tools, 1 held · all time 8 tools over 1 sessions ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
skeleton-guard · this session 8 tools, 1 held · all time 8 tools over 1 sessions
README

skeleton-guard

A small Claude Code mod built in the Mod Skeleton tutorial. It counts Claude's tool calls, holds destructive shell commands (rm -rf, git push --force, git reset --hard), refuses @-mentioned secret files (.env, *.pem, SSH keys), and shows a tally on the spinner and in a band above the prompt. /skeleton prints the counts.

It is a teaching mod, not a safety control. Its guards fail closed when they throw (each has a .catch), but a determined command can still get past a list of patterns.

Install

Type this at the prompt of a Claude Code terminal session (2.1.287 or later), answer y to add the marketplace, then pick a scope:

/plugin install skeleton-guard --marketplace your-github-name/my-mod

To try it for one session without installing it:

claude --plugin-dir path/to/my-mod

What it reads and does

claude plugin validate . lists it. In short: it hooks session.start, command.run, classic.SessionStart, tool.call, prompt.mention and ui.render (Spinner and AbovePrompt), and it calls $.command.register, $.state, $.store and $.ui.resolve. It makes no network requests and starts no processes.

Develop

claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 96 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Tally } from '../types'
5
6// The Mod Skeleton, finished. Each block names the module that added it.
7
8// Module 6, widened in module 14: shell commands this mod holds.
9const DESTRUCTIVE = [
10  /\brm\s+-[a-z]*r[a-z]*f\b|\brm\s+-[a-z]*f[a-z]*r\b/,
11  /\bgit\s+push\b.*(--force\b|\s-f\b)/,
12  /\bgit\s+reset\s+--hard\b/,
13]
14// Module 8: files a prompt may not pull in with @.
15const SECRET = /(^|\/)\.env(\.|$)|\.pem$|(^|\/)id_(rsa|ed25519)$/
16
17// Modules 9 and 10: per-session counts in $.state, all-time counts in $.store.
18const ZERO: Tally = { sessions: 0, tools: 0, held: 0 }
19const KEY = 'all-time'
20const session = atom({ plugin: 'skeleton-guard', key: 'session' } as const, ZERO)
21const allTime = atom({ plugin: 'skeleton-guard', key: 'allTime' } as const, ZERO)
22
23async function stored($: EngineInterface): Promise<Tally> {
24  return ((await $.store.get(KEY)) as Tally | undefined) ?? ZERO
25}
26
27// $.store is the source of truth: every write starts from what is stored, never from $.state.
28async function bump($: EngineInterface, field: keyof Tally) {
29  if (field !== 'sessions') await update($, session, t => ({ ...t, [field]: t[field] + 1 }))
30  const before = await stored($)
31  const total = { ...before, [field]: before[field] + 1 }
32  await $.store.set(KEY, total)
33  await update($, allTime, () => total)
34}
35
36export const register: Register = on => {
37  // Module 5: a slash command. Register it when the session starts, answer it in command.run.
38  on('session.start', async ($, e, next) => {
39    await $.command.register({ name: 'skeleton', description: 'Show what skeleton-guard has counted and held' })
40    await bump($, 'sessions')
41    return next(e)
42  })
43
44  on('command.run', { command: 'skeleton' }, async $ => {
45    const now = await read($, session)
46    const ever = await read($, allTime)
47    return { text: `skeleton-guard: this session ${now.tools} tool calls, ${now.held} held; all time ${ever.tools} tool calls over ${ever.sessions} sessions.` }
48  })
49
50  // Module 10: /clear, /resume and /branch reset $.state and do not fire session.start again.
51  on('classic.SessionStart', async ($, e, next) => {
52    if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
53      const total = await stored($)
54      await update($, allTime, () => total)
55    }
56    return next(e)
57  })
58
59  // Modules 1, 6 and 7: count every tool call, hold destructive shell commands, fail closed.
60  on('tool.call', async ($, e, next) => {
61    if (e.tool === 'Bash') {
62      const command = e.command.trim()
63      if (DESTRUCTIVE.some(re => re.test(command))) {
64        await bump($, 'held')
65        return { deny: `skeleton-guard: "${command}" is destructive; ask the person to run it themselves.` }
66      }
67    }
68    await bump($, 'tools')
69    return next(e)
70  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'skeleton-guard could not check this call, so it is held.' }))
71
72  // Module 8: the second door. An @-mentioned file is read without any tool.call.
73  on('prompt.mention', { path: SECRET }, () => ({ deny: 'skeleton-guard: that file may hold secrets.' })).catch(
74    ($, e, next) => (next.called ? next(e) : { deny: 'skeleton-guard could not check this file, so it is held.' }),
75  )
76
77  // Module 4: rewrite what Claude Code draws. Keep the spinner, add a suffix.
78  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
79    const now = await read($, session)
80    return next({ ...e, props: { ...e.props, suffix: ` · tool calls: ${now.tools}…` } })
81  })
82
83  // Module 9: a band above the prompt, drawn from $.state, so a state write redraws it.
84  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
85    const now = await read($, session)
86    const ever = await read($, allTime)
87    if (e.props.hasSurvey || ever.sessions === 0) return next(e)
88    const { Text } = $.ui.resolve(e)
89    return (
90      <Text dimColor>
91        skeleton-guard · this session {now.tools} tools, {now.held} held · all time {ever.tools} tools over {ever.sessions} sessions
92      </Text>
93    )
94  })
95}
96
types/index.d.ts 8 lines
1export type Tally = { sessions: number; tools: number; held: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'skeleton-guard': { session: Tally; allTime: Tally }
6  }
7}
8