SLOPSHOPPER

trailer-guard

Per-repo commit attribution policy: refuses a git commit, gh pr create/edit or git push that would carry a Co-Authored-By: Claude trailer or the Generated with…

newguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trailer-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(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /trailers ⎿ trailer-guard: Attribution allowed here (no remote matches a none rule). Rules: tinkerologie/*=none, esphome/*=none, home-ass ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

crab-workshop

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.

The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.

ModWhat you getCommands
where-am-iStatus line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container./where
roll-callA pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message.`/rollcall [open\linked] [text], tool report`
fuel-gauge⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot./fuel [text]
gpu-crabWhile a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud./gpu
vitals-stripUnraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green.`/vitals [show\quiet]`
reefAn aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name./reef [text]
keymasterMasks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan./keymaster, /keymaster allow <tag>
trailer-guardRefuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable)./trailers
gatekeeperA pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you./gates [text], tool gates
codex-cornerA boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch./codex-card
house-rulesNotes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own./rules, /rules add …, /rules off <id>

Data sources

Everything is read-only.

ModReads
roll-call, fuel-gauge, reefThe desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage)
vitals-stripThe HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read
reefunraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview)
gpu-crab, reefOllama GET /api/ps at the ollamaUrl option (default http://localhost:11434)
gatekeepergh api graphql, one search query every 5 minutes and shortly after a push
where-am-i, trailer-guard, codex-cornergit in the session's checkout

Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:

"pluginConfigs": {
  "gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
  "vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
  "where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}

Trying them

For one terminal session:

claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge

For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}

Or install from this folder as a marketplace, so /reload-plugins picks up edits:

claude plugin marketplace add ~/dev/crab-workshop

Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.

Developing

sh scripts/check.sh            # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod

shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.

Known limits

  • The desktop Code tab's drawing of AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.
  • The Unraid Management Agent's own API (:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead.
  • keymaster masks what looks like a secret. A real value that slips past its patterns is not caught, and a fake one in a test fixture is masked until you /keymaster allow its tag.
  • gatekeeper reads the first 50 PRs of its search (newest first).

License

MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.

Source 2 files
hooks/register.ts 87 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { actionsIn, attributionLines, checkoutPolicy, denial, parsePolicy, policyFor } from './policy'
4import type { Rule } from './policy'
5
6let rules: Rule[] = []
7
8async function git($: EngineInterface, dir: string | undefined, args: string[]): Promise<string> {
9  try {
10    const r = await $.process.run(['git', ...args], { ...(dir ? { cwd: dir } : {}), timeoutMs: 8000 })
11    return r.exitCode === 0 ? r.stdout : ''
12  } catch {
13    return ''
14  }
15}
16
17async function policyOf($: EngineInterface, dir: string | undefined): Promise<{ policy: 'none' | 'allow'; because?: string }> {
18  const remotes = await git($, dir, ['remote', '-v'])
19  const urls = [...new Set(remotes.split('\n').map(l => l.split(/\s+/)[1] ?? '').filter(Boolean))]
20  return checkoutPolicy(urls, rules)
21}
22
23async function readFile($: EngineInterface, dir: string | undefined, file: string): Promise<string> {
24  try {
25    const t = await $.fs.read(file.startsWith('/') || !dir ? file : `${dir}/${file}`)
26    return typeof t === 'string' ? t : ''
27  } catch {
28    return ''
29  }
30}
31
32/** The refusal for a command, or null when it may run. */
33async function judge($: EngineInterface, command: string): Promise<string | null> {
34  const actions = actionsIn(command)
35  if (!actions.length) return null
36  const inline = attributionLines(command)
37  for (const a of actions) {
38    const p = a.kind === 'pr' && a.repo ? { policy: policyFor(a.repo, rules), because: a.repo } : await policyOf($, a.dir)
39    if (p.policy !== 'none') continue
40    const repo = p.because ?? 'this repo'
41    if (a.kind === 'push') {
42      // Commits on no remote yet: what this push would publish.
43      const lines = attributionLines(await git($, a.dir, ['log', '--not', '--remotes', '--format=%B', '-n', '50']))
44      if (lines.length) return denial(repo, lines, 'an unpushed commit')
45      continue
46    }
47    const lines = [...inline]
48    for (const f of a.files) lines.push(...attributionLines(await readFile($, a.dir, f)))
49    if (a.kind === 'commit' && a.isAmendNoMessage) lines.push(...attributionLines(await git($, a.dir, ['log', '-1', '--format=%B'])))
50    const unique = [...new Set(lines)]
51    if (unique.length) return denial(repo, unique, a.kind === 'pr' ? 'this PR text' : 'this commit message')
52  }
53  return null
54}
55
56export const register: Register = (on, options) => {
57  rules = parsePolicy(options.policy as readonly string[] | undefined)
58
59  on('session.start', async ($, e, next) => {
60    const started = await next(e)
61    await $.command.register({ name: 'trailers', description: "This repo's commit attribution policy (trailer-guard)" })
62    return started
63  })
64
65  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66    const why = await judge($, String(e.command ?? ''))
67    return why ? { deny: why } : next(e)
68  }).catch(($, e, next) => next(e))
69
70  // The engine's own attribution text, where the session's repo says none.
71  on('attribution.text', async ($, e, next) => {
72    if (e.kind !== 'commit' && e.kind !== 'pr') return next(e)
73    const p = await policyOf($, await $.session.cwd())
74    return p.policy === 'none' ? { text: '' } : next(e)
75  }).catch(($, e, next) => next(e))
76
77  on('command.run', { command: 'trailers' }, async $ => {
78    const p = await policyOf($, await $.session.cwd())
79    return {
80      text:
81        p.policy === 'none'
82          ? `No AI attribution here: a remote is ${p.because}. trailer-guard refuses commits, PR text and pushes that carry Co-Authored-By: Claude or the Claude Code footer.`
83          : `Attribution allowed here (no remote matches a none rule). Rules: ${rules.map(r => r.join('=')).join(', ')}`,
84    }
85  })
86}
87
hooks/policy.ts 79 lines
1// Pure logic for trailer-guard.
2
3export type Rule = [pattern: string, policy: 'none' | 'allow']
4
5export function parsePolicy(raw: readonly string[] | string | undefined): Rule[] {
6  const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[,\n]/) : []
7  return list
8    .map(s => String(s).trim())
9    .filter(s => s.includes('='))
10    .map(s => [s.slice(0, s.lastIndexOf('=')).trim(), s.slice(s.lastIndexOf('=') + 1).trim().toLowerCase()] as [string, string])
11    .filter((r): r is Rule => !!r[0] && (r[1] === 'none' || r[1] === 'allow'))
12}
13
14export const glob = (pattern: string, s: string): boolean =>
15  new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i').test(s)
16
17/** owner/name from a git remote URL. */
18export function slug(url: string): string | null {
19  const m = url.trim().match(/[:/]([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/)
20  return m && m[1] && m[2] ? `${m[1]}/${m[2]}` : null
21}
22
23export const policyFor = (repo: string, rules: Rule[]): 'none' | 'allow' => rules.find(([p]) => glob(p, repo))?.[1] ?? 'allow'
24
25/** The strictest policy among the repos a checkout's remotes name. */
26export function checkoutPolicy(remoteUrls: string[], rules: Rule[]): { policy: 'none' | 'allow'; because?: string } {
27  for (const u of remoteUrls) {
28    const s = slug(u)
29    if (s && policyFor(s, rules) === 'none') return { policy: 'none', because: s }
30  }
31  return { policy: 'allow' }
32}
33
34// Anywhere in a line, so a trailer passed as its own -m "..." argument counts too.
35const LINES = [/Co-Authored-By:\s*Claude\b[^"'\n]*/gi, /Co-Authored-By:[^"'\n]*noreply@anthropic\.com[^"'\n]*/gi, /(?:🤖\s*)?Generated with \[?Claude Code\]?[^"'\n]*/gi]
36
37/** The attribution lines in a text, as written, once each. */
38export function attributionLines(text: string): string[] {
39  const out = new Set<string>()
40  for (const re of LINES) for (const m of text.matchAll(re)) out.add(m[0].trim())
41  return [...out]
42}
43
44export type GitAction =
45  | { kind: 'commit'; dir?: string; files: string[]; isAmendNoMessage: boolean }
46  | { kind: 'pr'; dir?: string; files: string[]; repo?: string }
47  | { kind: 'push'; dir?: string }
48
49/** The git actions in a shell command (commits, PR creation or edits, pushes), with a leading cd or git -C. */
50export function actionsIn(command: string): GitAction[] {
51  const out: GitAction[] = []
52  let dir: string | undefined
53  const cd = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/)
54  if (cd && cd[1]) dir = cd[1].replace(/^["']|["']$/g, '')
55  const segments = command.split(/&&|;|\|\||\n(?=\s*(?:git|gh|cd)\b)/)
56  for (const seg of segments) {
57    const c = seg.trim()
58    const gitC = c.match(/^git\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
59    const d = gitC && gitC[1] ? gitC[1].replace(/^["']|["']$/g, '') : dir
60    const files = [...c.matchAll(/(?:\s-F|--file|--body-file)[=\s]+("[^"]+"|'[^']+'|[^\s"']+)/g)].map(m => (m[1] ?? '').replace(/^["']|["']$/g, '')).filter(f => f && f !== '-')
61    if (/^git\b(\s+-C\s+\S+)?\s+commit\b/.test(c))
62      out.push({ kind: 'commit', ...(d ? { dir: d } : {}), files, isAmendNoMessage: /--amend\b/.test(c) && !/(\s-m\b|\s-F\b|--message|--file|--no-edit)/.test(c) })
63    else if (/^gh\s+pr\s+(create|edit)\b/.test(c)) {
64      const repo = c.match(/(?:-R|--repo)[=\s]+(\S+)/)?.[1]
65      out.push({ kind: 'pr', ...(d ? { dir: d } : {}), files, ...(repo ? { repo } : {}) })
66    } else if (/^git\b(\s+-C\s+\S+)?\s+push\b/.test(c)) out.push({ kind: 'push', ...(d ? { dir: d } : {}) })
67  }
68  return out
69}
70
71export function denial(repo: string, lines: string[], where: string): string {
72  return (
73    `trailer-guard: ${repo} takes no AI attribution in commits or PRs, and ${where} carries:\n` +
74    lines.map(l => `  ${l}`).join('\n') +
75    `\nRemove ${lines.length === 1 ? 'that line' : 'those lines'} and run it again` +
76    (where === 'an unpushed commit' ? ' (reword the commit with git commit --amend, or git rebase with --exec for older ones).' : '.')
77  )
78}
79