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

Claude Code mod for the Syncthing-synced claude-code workspace. Applies only when the session root ends in /claude-code.
.gitignore (Read tool and shell read commands).batch/inputs/ and batch/outputs/ are allowed./plugin install workspace-guard --marketplace atsusta/workspace-guard-mod
Answer y to add the marketplace, then pick the user scope.
hooks/register.ts 67 lines1import 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