SLOPSHOPPER

shared-edit-guard

Asks before Claude edits a file that another tool (Gemini, an editor, another agent) changed in the last few minutes, through Edit/Write, a shell command that…

newguard
A shopper browsing a rack in a slop shop
README

claude-mods

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.

Source 2 files
hooks/register.ts 183 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ShellWindow } from '../types'
5
6// Before Edit/Write/NotebookEdit, a shell command that writes, or an MCP tool that writes a file, asks when
7// the file changed in the last few minutes by something other than this chat: Gemini or another agent on a
8// shared folder, an editor, a sync. Built-in Edit already refuses a file that changed since Claude read it;
9// this also covers a file Claude never read, and asks before Claude starts building on a half-finished
10// outside edit. A Cancel sticks to that outside version until the person says Edit anyway or it ages out.
11// $.fs.stat, $.clock and $.ui.ask only: no network, processes or model calls.
12
13const seen = atom({ plugin: 'shared-edit-guard', key: 'seen' } as const, {})
14const refused = atom({ plugin: 'shared-edit-guard', key: 'refused' } as const, {})
15const shells = atom({ plugin: 'shared-edit-guard', key: 'shells' } as const, [])
16
17const SLACK_MS = 2000
18const SEEN_MAX = 500
19const COMMAND_MAX = 2000
20const NAMED_MAX = 20
21const ASK_OPTIONS = { options: ['Cancel', 'Edit anyway'], header: 'Shared file' } as const
22
23// Windows paths (drive letter or backslashes) compare case-insensitively.
24const isWin = (p: string) => /^[A-Za-z]:|\\/.test(p)
25export const keyOf = (p: string) => (isWin(p) ? p.replace(/\\/g, '/').toLowerCase() : p)
26
27async function mtime($: EngineInterface, path: string): Promise<number | undefined> {
28  try {
29    const s = await $.fs.stat(path)
30    return s.kind === 'file' ? s.mtimeMs : undefined
31  } catch {
32    return undefined // no such file yet
33  }
34}
35
36// Versions older than the window can never cause a prompt, so they are dropped; the rest are capped.
37export const remembered = (all: Record<string, number>, key: string, m: number, cutoff: number) =>
38  Object.fromEntries(
39    Object.entries({ ...all, [key]: m })
40      .filter(([, t]) => t > cutoff)
41      .sort((a, b) => b[1] - a[1])
42      .slice(0, SEEN_MAX),
43  )
44
45async function remember($: EngineInterface, path: string, windowMs: number) {
46  const m = await mtime($, path)
47  if (m === undefined) return
48  const cutoff = (await $.clock.now()) - windowMs
49  await update($, seen, all => remembered(all, keyOf(path), m, cutoff))
50}
51
52// Does the command name this file (its basename as a whole word, so also any path ending in it)?
53const names = (command: string, path: string) => {
54  const fold = (s: string) => (isWin(path) ? s.toLowerCase() : s)
55  const base = fold(path.split(/[\\/]/).pop() ?? '')
56  const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57  return base !== '' && new RegExp(`(^|[^\\w.-])${esc}($|[^\\w.-])`).test(fold(command))
58}
59
60// Does the command look like it changes files: a redirect, PowerShell's writers, an in-place editor, a copy,
61// move or delete, a formatter or fixer, a one-liner that opens a file for writing?
62const WRITES = new RegExp(
63  [
64    String.raw`(?:^|[^\d&>=-])>{1,2}(?!\s*(?:&|/dev/null|\$null\b|nul\b))`,
65    String.raw`\b(?:Set-Content|Add-Content|Out-File|Clear-Content|Tee-Object|New-Item|Remove-Item|Move-Item|Copy-Item|Rename-Item|Export-Csv|Export-Clixml|WriteAll(?:Text|Lines|Bytes)|AppendAll(?:Text|Lines))\b`,
66    String.raw`\b(?:sed|perl)\b[^|;&]*\s(?:-\w*i\b|--in-place)`,
67    String.raw`\b(?:tee|cp|mv|rm|del|copy|move|ren|truncate|touch|patch|dd)\b`,
68    String.raw`\bgit\s+(?:checkout|restore|apply|stash|reset|mv|rm)\b`,
69    String.raw`\b(?:black|isort|autopep8|yapf|gofmt|rustfmt|clang-format)\b|\bdotnet\s+format\b|\bruff\s+format\b`,
70    String.raw`\b(?:prettier|eslint|ruff)\b[^|;&]*\s--(?:write|fix)\b`,
71    String.raw`\bopen\([^)]*['"][wax]\+?['"]|\.write(?:_text|_bytes)?\(|\b(?:writeFileSync|appendFileSync)\b`,
72  ].join('|'),
73  'i',
74)
75export const writes = (command: string) => WRITES.test(command)
76
77// The files a command names: quoted paths whole (they may hold spaces), and path-looking words with an extension.
78export const named = (command: string) => {
79  const quoted: string[] = []
80  const rest = command.replace(/"([^"]+)"|'([^']+)'/g, (all: string, a?: string, b?: string) => {
81    const s = a ?? b ?? ''
82    if (!/^(?:[A-Za-z]:)?[^*?<>|:]*\.\w{1,10}$/.test(s)) return all
83    quoted.push(s)
84    return ' '
85  })
86  const words = rest.match(/(?:[A-Za-z]:)?(?:[\w.~-]*[\\/])*[\w.-]*\w\.\w{1,10}(?![\w\\/])/g) ?? []
87  return [...new Set([...quoted, ...words])].filter(p => !/^\w+:\/\//.test(p)).slice(0, NAMED_MAX)
88}
89
90// Parallel agents keep some shell running almost all the time, so a window only covers changes its own
91// command could have made: to files it names, and only when it looks like it writes (a 9-minute run that
92// reads a config does not make an outside edit to that config its own).
93export const byOwnShell = (m: number, path: string, windows: readonly ShellWindow[]) =>
94  windows.some(w => m >= w.from - SLACK_MS && m <= w.to + SLACK_MS && writes(w.command ?? '') && names(w.command ?? '', path))
95
96const ago = (ms: number) => (ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`)
97const short = (p: string) => p.replace(/\\/g, '/').split('/').slice(-2).join('/')
98
99// Asks when the file changed outside this chat within the window; answers the deny text, or undefined to go
100// on. A Cancel is kept for that outside version, so reading the file does not turn the next change into a
101// silent one: it asks again until the person says Edit anyway or the change ages out of the window.
102async function check($: EngineInterface, path: string, windowMs: number): Promise<string | undefined> {
103  const m = await mtime($, path)
104  if (m === undefined) return undefined
105  const now = await $.clock.now()
106  const key = keyOf(path)
107  if (now - m >= windowMs) return undefined
108  const isRefused = (await read($, refused))[key] === m
109  if (!isRefused && ((await read($, seen))[key] === m || byOwnShell(m, path, await read($, shells)))) return undefined
110  let answer: string
111  try {
112    answer = await $.ui.ask(`${short(path)} was changed outside this chat ${ago(now - m)} ago (Gemini or another tool?). Edit it anyway?`, ASK_OPTIONS)
113  } catch {
114    answer = ''
115  }
116  if (answer === 'Edit anyway') {
117    await update($, refused, all => Object.fromEntries(Object.entries(all).filter(([k]) => k !== key)))
118    return undefined
119  }
120  await update($, refused, all => remembered(all, key, m, now - windowMs))
121  return `shared-edit-guard: ${path} was changed outside this chat ${ago(now - m)} ago and the user did not approve changing it. Do not retry, and do not change it another way (a shell command such as Set-Content, Out-File, sed -i or a redirect, a script, or another tool): tell the user what you meant to change and wait for them. Reading the file is fine; the next change asks again.`
122}
123
124// MCP tools whose name says they write, and the path-looking arguments they write to.
125const MCP_WRITES = /write|edit|create|update|move|rename|delete|remove|append|patch|save|replace|insert/i
126export const mcpPaths = (tool: string, input: Record<string, unknown>) =>
127  tool.startsWith('mcp__') && MCP_WRITES.test(tool.split('__').slice(2).join('__'))
128    ? Object.entries(input)
129        .filter(([k, v]) => /path|file|dest|target|source/i.test(k) && typeof v === 'string' && /[\\/]|\.\w{1,10}$/.test(v))
130        .map(([, v]) => v as string)
131    : []
132
133export const register: Register = (on, options) => {
134  const windowMs = Math.max(1, Number(options.windowMinutes ?? 10)) * 60_000
135
136  // Gate: judge before next, so the .catch below can refuse on any failure.
137  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
138    const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
139    const deny = await check($, path, windowMs)
140    if (deny) return { deny }
141    const ran = await next(e)
142    if (!ran.deny && !ran.isError) await remember($, path, windowMs)
143    return ran
144  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'shared-edit-guard: the shared-file check failed, so the edit was blocked.' }))
145
146  // Ledger: the version Claude read is one it knows.
147  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
148    const ran = await next(e)
149    if (!ran.deny && !ran.isError) await remember($, e.file_path, windowMs).catch(() => undefined)
150    return ran
151  }).catch(($, e, next) => next(e))
152
153  // A shell command that looks like it writes asks first about the files it names. Changes made while one
154  // ran, to files it names, are its own.
155  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
156    const command = String(e.command ?? '').slice(0, COMMAND_MAX)
157    if (writes(command)) {
158      for (const path of named(command)) {
159        const deny = await check($, path, windowMs)
160        if (deny) return { deny }
161      }
162    }
163    const from = await $.clock.now()
164    const ran = await next(e)
165    const to = await $.clock.now()
166    await update($, shells, list => [...list, { from, to, command }].filter(w => to - w.to < 24 * 60 * 60_000).slice(-200)).catch(() => undefined)
167    return ran
168  }).catch(($, e, next) => next(e))
169
170  // MCP tools that write a file (a filesystem server's write_file, edit_file, move_file) ask the same way.
171  on('tool.call', async ($, e, next) => {
172    const paths = mcpPaths(String(e.tool), e as Record<string, unknown>)
173    if (paths.length === 0) return next(e)
174    for (const path of paths) {
175      const deny = await check($, path, windowMs)
176      if (deny) return { deny }
177    }
178    const ran = await next(e)
179    if (!ran.deny && !ran.isError) for (const path of paths) await remember($, path, windowMs).catch(() => undefined)
180    return ran
181  }).catch(($, e, next) => next(e))
182}
183
types/index.d.ts 16 lines
1/** command: the shell command text, truncated; only files it names count as its own changes, and only when it looks like it writes */
2export type ShellWindow = { from: number; to: number; command: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'shared-edit-guard': {
7      /** normalized path -> mtimeMs of the version this chat last read or wrote */
8      seen: Record<string, number>
9      /** normalized path -> mtimeMs of an outside version the user answered Cancel for; asked again until Edit anyway or it ages out */
10      refused: Record<string, number>
11      /** when this chat's own shell commands ran and what they said, so changes to files they name count as its own */
12      shells: ShellWindow[]
13    }
14  }
15}
16