SLOPSHOPPER

sandbox-guard

Denies Edit/Write/Bash writes to Claude config paths the sandbox cannot write (settings, skills, plugins, hooks, projects, ...) and tells Claude to hand the…

newguard
v0.1.5no licenseupdated 2026-10-08oleksandrkodua/qa-mods/sandbox-guard
A shopper browsing a rack in a slop shop
README

qa-mods

Fourteen Claude Code mods, packaged as one plugin marketplace (qa-mods). Made for a manual QA workflow: guard rails around risky commands, evidence for what was tested, and a status band above the prompt.

Install

Run in your own Terminal (a sandboxed Claude session cannot write ~/.claude).

claude plugin marketplace add oleksandrkodua/qa-mods
claude plugin install hud@qa-mods

Replace hud with any plugin from the table.

To install everything from a local clone, or to set up a fresh machine, see INSTALL.md and install-pack.sh.

Plugins

PluginWhat it does
sandbox-guardDenies writes to protected Claude config paths and hands back a Terminal command
remote-gateAsks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out
blast-radiusShows what a risky Bash command will touch before it runs
retry-analyzerDetects repeated identical failing tool calls and tells Claude to change strategy
secret-redactorMasks tokens and keys in tool output
evidence-saverSaves screenshots from tool results to a folder you pick
replay-theater/replay: step through the session's file edits one by one
verification-guardChecks claims like "tests pass" against tool calls that actually ran
handoffOffers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt
notifyA toast and a macOS notification when a long command or turn finishes
quick-actionsCompact and Clear buttons, each asks to confirm first
hudContext fill and rate-limit windows above the prompt, a кеш 59:48 chip (m:ss) while the prompt cache is warm, five buttons, and /hud with the figures as text
next-steps-ukFork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps
plan-progressFork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods

Tests

claude plugin test hud                                              # from the repo root
cd hud && node --test tests/format.test.mjs tests/handoff-prompt.test.mjs

A bare node --test also picks up register.test.ts, which only runs under claude plugin test.

License

No license file for the original mods yet. The two forks keep their upstream MIT notices: next-steps-uk/NOTICE.md and plan-progress/NOTICE.md (plus plan-progress/LICENSE.upstream).

Source 2 files
hooks/register.ts 27 lines
1import type { Register } from 'claude-code'
2
3import { checkCommand, checkPath, denyText } from './guard'
4
5/** Sandbox Guard: refuses writes to protected config paths up front. */
6export const register: Register = on => {
7  let home = ''
8
9  on('session.start', async ($, e, next) => {
10    home = String((await $.env.get('HOME')) ?? '')
11
12    return next(e)
13  })
14
15  on('tool.call', { tool: ['Edit', 'Write'] }, ($, e, next) => {
16    const hit = home ? checkPath(String(e.file_path ?? ''), home) : null
17
18    return hit ? { deny: denyText(hit, `${e.tool} on ${e.file_path}`) } : next(e)
19  })
20
21  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
22    const hit = home ? checkCommand(String(e.command ?? ''), home) : null
23
24    return hit ? { deny: denyText(hit, 'this command') } : next(e)
25  })
26}
27
hooks/guard.ts 118 lines
1export const PROTECTED = [
2  '~/.claude/settings',
3  '~/.claude/projects/',
4  '~/.claude/skills/',
5  '~/.claude/plugins/',
6  '~/.claude/hooks/',
7  '~/.local/',
8  '/opt/homebrew',
9  '~/Library/Application Support/Claude/',
10]
11
12const WRITEISH = /((?<![=>&-])>(?![=&])|\btee\b|\bsed\s+-i|\bcp\b|\bmv\b|\brm\b|\bmkdir\b|\btouch\b|\bchmod\b|\bln\b|\bnpm\s+(i|install)\b|\bbrew\s+install\b|\bpip3?\s+install\b)/
13
14function expand(p: string, home: string) {
15  return p.startsWith('~/') ? home + p.slice(1) : p
16}
17
18export function protectedHit(text: string, home: string): string | null {
19  const t = text.replaceAll('$HOME', home).replaceAll('~', home)
20
21  for (const p of PROTECTED) {
22    if (t.includes(expand(p, home))) return p
23  }
24
25  return null
26}
27
28export function checkPath(path: string, home: string) {
29  return protectedHit(path, home)
30}
31
32/** Drop heredoc bodies (documents/code fed to another program) unless a shell reads them. */
33export function stripHeredocs(cmd: string): string {
34  const out: string[] = []
35  let end: string | null = null
36  let keep = false
37
38  for (const line of cmd.split('\n')) {
39    if (end !== null) {
40      if (line.trim() === end) end = null
41      else if (keep) out.push(line)
42
43      continue
44    }
45
46    out.push(line)
47
48    const m = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(line)
49
50    if (m) {
51      end = m[2]!
52      keep = /\b(ba|z|da|)sh\s*(-\w+\s*)*(-\s*)?<</.test(line)
53    }
54  }
55
56  return out.join('\n')
57}
58
59const maskQuotes = (s: string) => s.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
60
61const words = (seg: string) => [...seg.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
62
63const COPY = new Set(['cp', 'mv', 'ln', 'install', 'rsync'])
64const EVERY = new Set(['rm', 'rmdir', 'unlink', 'shred', 'mkdir', 'touch', 'chmod', 'chown', 'tee', 'truncate'])
65
66/** The words a write verb in this segment writes to: the last argument of cp/mv/ln, every argument of rm/mkdir/tee, files of sed -i, redirect targets. */
67function writeTargets(seg: string): string[] {
68  const out = [...seg.matchAll(/(?<![=>&-])>>?(?![=&])\s*("[^"]*"|'[^']*'|\S+)/g)].map(m => m[1]!.replace(/^["']|["']$/g, ''))
69  const w = words(seg)
70
71  w.forEach((word, i) => {
72    const rest = w.slice(i + 1)
73    const args = rest.filter(x => !x.startsWith('-'))
74
75    if (COPY.has(word)) {
76      const dir = rest.findIndex(x => x === '-t')
77      const long = rest.find(x => x.startsWith('--target-directory='))
78
79      if (dir >= 0 && rest[dir + 1]) out.push(rest[dir + 1]!)
80      else if (long) out.push(long.slice('--target-directory='.length))
81      else if (args.length > 0) out.push(args[args.length - 1]!)
82    } else if (EVERY.has(word)) out.push(...args)
83    else if (word === 'sed' && rest.some(x => /^-\w*i/.test(x))) out.push(...args)
84  })
85
86  return out
87}
88
89export function checkCommand(raw: string, home: string) {
90  // judge each shell segment alone: `cp a b && ls ~/.claude/projects` is a write and a read, not a write there
91  for (const seg of stripHeredocs(raw).split(/\n|;|&&|\|\||\|/)) {
92    const masked = maskQuotes(seg)
93
94    // the write verb must be real shell, not text inside a quoted string; the path itself may be quoted
95    if (!WRITEISH.test(masked)) continue
96
97    // installers write somewhere we cannot name: any mention of a protected path counts
98    if (/\b(npm\s+(i|install)|brew\s+install|pip3?\s+install)\b/.test(masked)) {
99      const hit = protectedHit(seg, home)
100
101      if (hit) return hit
102    }
103
104    // other verbs: only a protected path that is written to counts; a source of cp/mv or an argument of cat is only read
105    for (const target of writeTargets(seg)) {
106      const hit = protectedHit(target, home)
107
108      if (hit) return hit
109    }
110  }
111
112  return null
113}
114
115export const denyText = (hit: string, what: string) =>
116  `sandbox-guard: BLOCKED — ${what} touches ${hit}, which this environment cannot write. Do not try to work around it. ` +
117  `Tell the user what was blocked, give them one ready-to-run command for their own Terminal, and ask them to send back the output.`
118