Refuses a shell write to a shared file (STATUS.md, CLAUDE.md, MEMORY.md, or the files you name) that this session has not read, or that another session changed…

A Claude Code mod for running several sessions on the same project. It refuses a shell command that would write a shared file (STATUS.md, CLAUDE.md, MEMORY.md, or the files you name) when this session never read it, or another session changed it since. Part of claude-plugins.
python3 scripts, sed -i, tee, mv and cp.cat or grep counts as a read; a session's own writes do not block its next one; creating a file that does not exist yet is allowed./shared-file-guard lists the files this session has seen; /shared-file-guard off|on.| Setting | Default | What it does |
|---|---|---|
watched_files | STATUS.md,CLAUDE.md,MEMORY.md | Comma separated file names; * matches any run of name characters, such as *register*.md |
tool.call on Read, Edit and Write only records what was read. tool.call on Bash may refuse that one command; it never changes it. command.run answers its own /shared-file-guard command and no other.Needs Claude Code 2.1.287 or later (mods). Tested on 2.1.289.
/plugin marketplace add mutlumehmet/claude-plugins
/plugin install shared-file-guard@mehmetmutlu
A mod runs inside Claude Code with your permissions. Read the code before you install any mod. Settings are asked for at install, or later with /plugin configure shared-file-guard@mehmetmutlu.
cd in the same command, are not seen.Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.
hooks/register.js 186 lines1// shared-file-guard: refuses a Bash command that would write a shared file
2// (STATUS.md, CLAUDE.md and MEMORY.md by default; the watched_files setting) when this
3// session never read it, or another session changed it since this session
4// last saw it. The Edit and Write tools already refuse a stale file on their
5// own, so the guard only records what they saw; the gap it closes is the
6// shell: heredocs, python scripts, sed -i, tee, mv and cp.
7//
8// It fails open: if the hook crashes, the command runs as it would without
9// the mod, because blocking every Bash call on a bug would be worse than the
10// race it guards against.
11
12// Basenames that count as shared, from the watched_files setting: names, * matching any run of
13// name characters (such as *register*.md)
14const DEFAULT_WATCHED = 'STATUS.md,CLAUDE.md,MEMORY.md'
15let SHARED = /$^/
16// Path-like tokens in a shell command that end in a shared basename
17let TOKEN = /$^/g
18
19export function configure(options) {
20 const names = String(options?.watched_files || DEFAULT_WATCHED).split(',').map((x) => x.trim()).filter(Boolean)
21 const alt = (star) => names.map((n) => n.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join(star)).join('|')
22 SHARED = new RegExp('(^|/)(' + alt('[^/\\s\'"]*') + ')$', 'i')
23 TOKEN = new RegExp('[~\\w./-]*(?:' + alt('[\\w.-]*') + ')', 'gi')
24}
25
26// Anything that can change a file. -i counts only after sed or perl (an in place edit); for grep
27// it means ignore case. Redirects to a file descriptor or /dev/null are removed before this runs.
28// Interpreters count as writes, because a script can open the file for writing.
29const WRITES = /(>|\btee\b|\b(?:sed|perl)\b[^|;&]*\s-i(?:\b|['"])|--in-place|\bmv\b|\bcp\b|\brm\b|\btruncate\b|\bdd\b|\bpython3?\b|\bnode\b|\bperl\b|\bruby\b|\bosascript\b|\bgit\s+(checkout|restore|reset|stash|apply|merge|rebase|pull|mv|rm)\b)/
30
31// What this session last saw of each shared file: absolute path -> "mtimeMs:size"
32const seen = new Map()
33let isOn = true
34
35export function register(on, options) {
36 configure(options)
37 on('session.start', async ($, e, next) => {
38 await $.command.register({
39 name: 'shared-file-guard',
40 description: 'Show which shared files this session has seen, or turn the guard off|on',
41 argumentHint: '[off|on]',
42 })
43 return next(e)
44 })
45
46 on('command.run', { command: 'shared-file-guard' }, async ($, e) => {
47 const arg = String(e.args ?? '').trim()
48 if (arg === 'off') isOn = false
49 if (arg === 'on') isOn = true
50 const files = [...seen.keys()]
51 return {
52 text: 'Guard ' + (isOn ? 'on' : 'off') + '. Shared files this session has seen: ' + (files.length ? files.join(', ') : 'none yet'),
53 }
54 })
55
56 // Read, Edit and Write: let them run, then remember the version they left
57 on('tool.call', { tool: ['Read', 'Edit', 'Write'] }, async ($, e, next) => {
58 const result = await next(e)
59 if (!result.deny && !result.isError && e.file_path && SHARED.test(e.file_path)) {
60 await remember($, e.file_path)
61 }
62 return result
63 })
64
65 // Bash: check before a write, remember after any command that touched one
66 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
67 const paths = await sharedPaths($, e.command)
68 if (!paths.length) return next(e)
69
70 // Only a shared file named in the same part of the command as the write is checked, so
71 // "sed -i ... other.txt && grep x CLAUDE.md" is a write to other.txt and a read of CLAUDE.md
72 const writing = commandParts(e.command).filter(looksLikeWrite).join('\n')
73 const targets = writing ? await sharedPaths($, writing) : []
74 if (isOn && targets.length) {
75 const problems = []
76 for (const p of targets) {
77 const now = await version($, p)
78 if (now === null) continue // does not exist yet: creating it is fine
79 const before = seen.get(p)
80 if (before === undefined) problems.push(p + ' has not been read in this session')
81 else if (before !== now) problems.push(p + ' changed since this session last read it (another session or process wrote it)')
82 }
83 if (problems.length) {
84 $.ui.log('shared-file-guard held a write: ' + problems.join('; '))
85 return {
86 deny:
87 'shared-file-guard refused this command, so nothing ran. ' + problems.join('; ') + '. ' +
88 'Read the file again with the Read tool, merge your change into what is there now, then write it. ' +
89 'If the user wants to skip the check, they can run /shared-file-guard off.',
90 }
91 }
92 }
93
94 const result = await next(e)
95 // Whatever the command did, the file as it is now is what this session has seen
96 for (const p of paths) await remember($, p)
97 return result
98 })
99}
100
101function looksLikeWrite(command) {
102 const cleaned = String(command)
103 .replace(/\d*>&\d/g, ' ')
104 .replace(/&>\s*\/dev\/null/g, ' ')
105 .replace(/\d*>\s*\/dev\/null/g, ' ')
106 return WRITES.test(cleaned)
107}
108
109// Absolute paths of the shared files a command names that exist on disk now
110// The parts of a shell command that run separately: split at unquoted &&, ||, ; and |. A command
111// with a heredoc stays whole, because the script inside it can write anywhere.
112export function commandParts(command) {
113 const text = String(command)
114 if (/<<-?\s*['"]?\w/.test(text)) return [text]
115 const parts = []
116 let cur = '', quote = null
117 for (let i = 0; i < text.length; i++) {
118 const c = text[i]
119 if (quote) {
120 if (c === quote) quote = null
121 cur += c
122 continue
123 }
124 if (c === "'" || c === '"') {
125 quote = c
126 cur += c
127 continue
128 }
129 if ((c === '&' || c === '|') && text[i + 1] === c) {
130 parts.push(cur)
131 cur = ''
132 i++
133 continue
134 }
135 if (c === ';' || c === '|' || c === '\n') {
136 parts.push(cur)
137 cur = ''
138 continue
139 }
140 cur += c
141 }
142 parts.push(cur)
143 return parts.map((x) => x.trim()).filter(Boolean)
144}
145
146async function sharedPaths($, command) {
147 const tokens = String(command).match(TOKEN) ?? []
148 if (!tokens.length) return []
149 const home = await $.env.get('HOME')
150 const cwd = await $.session.cwd()
151 const out = new Set()
152 for (const t of tokens) {
153 let p = t
154 if (p.startsWith('~/')) p = home + p.slice(1)
155 else if (!p.startsWith('/')) p = cwd + '/' + p
156 p = normalize(p)
157 if (SHARED.test(p) && (await version($, p)) !== null) out.add(p)
158 }
159 return [...out]
160}
161
162function normalize(p) {
163 const parts = []
164 for (const part of p.split('/')) {
165 if (part === '' || part === '.') continue
166 if (part === '..') parts.pop()
167 else parts.push(part)
168 }
169 return '/' + parts.join('/')
170}
171
172async function version($, path) {
173 try {
174 const s = await $.fs.stat(path)
175 return s.kind === 'file' ? s.mtimeMs + ':' + s.size : null
176 } catch {
177 return null
178 }
179}
180
181async function remember($, path) {
182 const p = normalize(path)
183 const v = await version($, p)
184 if (v !== null) seen.set(p, v)
185}
186