SLOPSHOPPER

shared-file-guard

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…

newguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shared-file-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /shared-file-guard ⎿ shared-file-guard: Guard on. Shared files this session has seen: none yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

shared-file-guard

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.

What it does

  • The Edit and Write tools already refuse a file that changed since it was read. This closes the shell gap: heredocs, python3 scripts, sed -i, tee, mv and cp.
  • The refusal names the file and tells Claude to read it again, merge, then write.
  • A 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.
  • If the mod fails, the command runs as normal.

Settings

SettingDefaultWhat it does
watched_filesSTATUS.md,CLAUDE.md,MEMORY.mdComma separated file names; * matches any run of name characters, such as *register*.md

What it reads and does

  • Reads: which shared files (the project's status, instructions, memory and register files) this session read and when they last changed on disk, and the command line of each shell command.
  • Does: refuses a shell write to a shared file that another session changed since this one read it, and logs a line saying so.
  • Hooks: 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.
  • Privacy: see PRIVACY.md.
  • Never: sends anything anywhere (no network calls, no telemetry).

Install

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.

Known gaps

  • Paths built inside a script, and relative paths after a cd in the same command, are not seen.
  • It compares date and size, not a hash.
  • It checks then writes, it does not lock. Two sessions that pass the check within the same few milliseconds can still overwrite each other. A hook runs before the command, not around it, so it cannot hold a lock while the shell writes.

About

Mehmet Mutlu: work that's shipped, at mehmetmutlu.dev

Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.

License

MIT

Source 1 files
hooks/register.js 186 lines
1// 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