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

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.
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.
| Plugin | What it does |
|---|---|
sandbox-guard | Denies writes to protected Claude config paths and hands back a Terminal command |
remote-gate | Asks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out |
blast-radius | Shows what a risky Bash command will touch before it runs |
retry-analyzer | Detects repeated identical failing tool calls and tells Claude to change strategy |
secret-redactor | Masks tokens and keys in tool output |
evidence-saver | Saves screenshots from tool results to a folder you pick |
replay-theater | /replay: step through the session's file edits one by one |
verification-guard | Checks claims like "tests pass" against tool calls that actually ran |
handoff | Offers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt |
notify | A toast and a macOS notification when a long command or turn finishes |
quick-actions | Compact and Clear buttons, each asks to confirm first |
hud | Context 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-uk | Fork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps |
plan-progress | Fork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods |
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.
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).
hooks/register.ts 27 lines1import 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}
27hooks/guard.ts 118 lines1export 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