SLOPSHOPPER

workspace-guard

Blocks reading .gitignore and creating venvs or heavy media inside the Syncthing-synced claude-code workspace.

newguard
v0.1.0Apache-2.0updated 2026-10-06atsusta/workspace-guard-mod
A shopper browsing a rack in a slop shop
README

workspace-guard

Claude Code mod for the Syncthing-synced claude-code workspace. Applies only when the session root ends in /claude-code.

  • Denies reading .gitignore (Read tool and shell read commands).
  • Denies creating venvs or heavy media (mp4, mov, psd, safetensors, zip, ...) inside the workspace; batch/inputs/ and batch/outputs/ are allowed.
  • Fails open: if the mod errors, the call goes through.

Install

/plugin install workspace-guard --marketplace atsusta/workspace-guard-mod

Answer y to add the marketplace, then pick the user scope.

Source 1 files
hooks/register.ts 67 lines
1import type { Register } from 'claude-code'
2
3const GITIGNORE = /(^|[\s"'/=])\.gitignore(\s|$|["'])/
4const READ_VERBS = /(^|[\s;&|(])(cat|head|tail|less|more|bat|sed|awk|grep|rg|nl|wc|cut|sort|strings|xxd|open)\s/
5const HEAVY_EXT = /\.(mp4|mov|mkv|avi|webm|wav|flac|psd|clip|safetensors|ckpt|gguf|iso|zip|7z|tar|tgz)$/i
6const HEAVY_WRITERS = /(^|[\s;&|(])(yt-dlp|wget|curl|ffmpeg|cp|mv)\s/
7const VENV = /(python3?\s+-m\s+venv|virtualenv|uv\s+venv)(\s+(\S+))?/
8const ALLOWED_DIRS = ['batch/inputs/', 'batch/outputs/']
9
10const isWorkspace = (cwd: string) => cwd.endsWith('/claude-code')
11
12// Path relative to the workspace root, or undefined when the path is outside it.
13const inside = (p: string, cwd: string): string | undefined => {
14  if (p.startsWith('~') || p.startsWith('../')) return undefined
15  if (p.startsWith('/')) return p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : undefined
16  return p.replace(/^\.\//, '')
17}
18
19const heavyNote = (name: string, what: string) =>
20  `${name}: ${what} would sit inside the Syncthing-synced workspace. Keep venvs in ~/.venvs/<name> and raw media or bulk intermediates in a scratch dir; only final deliverables go in batch/outputs/.`
21
22export const register: Register = on => {
23  on('tool.call', { tool: 'Read' }, ($, e, next) =>
24    /(^|\/)\.gitignore$/.test(e.file_path)
25      ? { deny: `${$.plugin.name}: reading .gitignore is off limits in this workspace.` }
26      : next(e),
27  ).catch(($, e, next) => next(e))
28
29  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
30    const cwd = await $.session.root()
31    const rel = isWorkspace(cwd) ? inside(e.file_path, cwd) : undefined
32    if (rel && HEAVY_EXT.test(rel) && !ALLOWED_DIRS.some(d => rel.startsWith(d)))
33      return { deny: heavyNote($.plugin.name, rel) }
34    return next(e)
35  }).catch(($, e, next) => next(e))
36
37  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
38    const cmd = e.command
39    if (GITIGNORE.test(cmd) && READ_VERBS.test(cmd))
40      return { deny: `${$.plugin.name}: reading .gitignore is off limits in this workspace.` }
41
42    const cwd = await $.session.root()
43    if (!isWorkspace(cwd)) return next(e)
44
45    const venv = VENV.exec(cmd)
46    if (venv) {
47      const target = venv[3] && !venv[3].startsWith('-') ? venv[3] : '.venv'
48      if (inside(target, cwd) !== undefined) return { deny: heavyNote($.plugin.name, `a venv at ${target}`) }
49    }
50
51    const writer = HEAVY_WRITERS.exec(cmd)
52    if (writer) {
53      // ffmpeg, cp and mv read their inputs first; only the last argument is the destination.
54      const tokens = cmd.trim().split(/\s+/)
55      const targets = ['ffmpeg', 'cp', 'mv'].includes(writer[2]) ? tokens.slice(-1) : tokens
56      for (const token of targets) {
57        const path = token.replace(/^["']|["']$/g, '')
58        if (!HEAVY_EXT.test(path)) continue
59        const rel = inside(path, cwd)
60        if (rel && !ALLOWED_DIRS.some(d => rel.startsWith(d)))
61          return { deny: heavyNote($.plugin.name, rel) }
62      }
63    }
64    return next(e)
65  }).catch(($, e, next) => next(e))
66}
67