SLOPSHOPPER

read-guard

Holds a whole-file Read (no offset/limit) of a file above the limit, a lockfile, minified code or a source map, and the same via cat, type or Get-Content in…

newguardtoast
A shopper browsing a rack in a slop shop
README

Read Guard

Holds a whole-file Read (no offset/limit) of a file above the limit, a lockfile, minified code or a source map, and the same via cat, type or Get-Content in the shell; tells Claude the size, a token estimate and a better way (Grep, offset/limit). A second try at the same file goes through, and so does a Read with allow_large. Claude Code itself stops only at about 25,000 tokens.

Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install read-guard --marketplace LupusMalusDeviant/claude-mods

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
limitKb50Files above this size are held when read whole. off: only lockfiles, minified files and source maps.
modeblockblock: hold the first try and tell Claude a better way. warn: only a notice.
shellonon: also cat, type and Get-Content of a whole file.
languageenLanguage of the texts this mod shows and writes.

License

MIT, see LICENSE.

Source 2 files
hooks/entry.ts 9 lines
1// Entry of the standalone Read Guard: the Mod Box code, registered on its own.
2import type { Register } from 'claude-code'
3
4import { register as inner } from '../mods/read-guard/hooks/register'
5
6export const register: Register = (on, options) => {
7  ;(inner as any)(on, options)
8}
9
mods/read-guard/hooks/register.tsx 124 lines
1// Read Guard: stops Claude from pulling a whole large or useless file into the context. A Read
2// without offset/limit of a file above the limit, a lockfile, minified code or a source map is
3// held with its size and a token estimate and a better way (Grep, offset/limit). The same with
4// cat, type or Get-Content in the shell, which knows no limit at all. Claude Code's own Read stops
5// only at about 25,000 tokens; this guard is the cheaper line before that. A second try at the
6// same file goes through, and so does a Read with allow_large: Claude may really need it.
7import type { EngineInterface, Register } from 'claude-code'
8
9let lang: 'de' | 'en' = 'en'
10const t = (de: string, en: string) => (lang === 'en' ? en : de)
11let mode = 'block'
12let limitKb = 50
13let shell = true
14// Files held once this session: the second try goes through.
15const held = new Set<string>()
16
17// Files Read turns into something else than text (images, PDFs, notebooks): not ours.
18const MEDIA = /\.(png|jpe?g|gif|webp|bmp|ico|pdf|ipynb)$/i
19const LOCK = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|Cargo\.lock|poetry\.lock|uv\.lock|Pipfile\.lock|composer\.lock|Gemfile\.lock|go\.sum|packages\.lock\.json|flake\.lock)$/i
20const MINIFIED = /\.min\.(js|mjs|css)$|\.map$/i
21// Below this even a lockfile or a minified file costs too little to bother.
22const NOISE_MIN = 8 * 1024
23
24export type Verdict = { kind: 'lock' | 'minified' | 'large'; size: number; tokens: number } | null
25
26/** Why a whole-file read of this path and size is a waste; null when it is fine. Pure, for the tests. */
27export function judge(path: string, size: number, limit: number): Verdict {
28  const p = path.replace(/\\/g, '/')
29  if (MEDIA.test(p)) return null
30  const tokens = Math.round(size / 4)
31  if (size >= NOISE_MIN && LOCK.test(p)) return { kind: 'lock', size, tokens }
32  if (size >= NOISE_MIN && MINIFIED.test(p)) return { kind: 'minified', size, tokens }
33  if (limit > 0 && size > limit) return { kind: 'large', size, tokens }
34  return null
35}
36
37/** The file a plain shell dump reads (cat, type, Get-Content), or null. Not when piped, limited or several. */
38export function shellDump(command: string): string | null {
39  const c = command.trim()
40  // piped into grep/head/…, chained, or redirected: the output is already shaped
41  if (/[|;&<>\n]/.test(c)) return null
42  const words = c.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
43  if (!/^(cat|type|gc|get-content)$/i.test(words[0] ?? '')) return null
44  const files: string[] = []
45  for (const w of words.slice(1)) {
46    // -Raw, -Path, -LiteralPath change nothing; -n, -TotalCount, -Tail … shape the output
47    if (w.startsWith('-')) {
48      if (/^-(raw|path|literalpath)$/i.test(w)) continue
49      return null
50    }
51    files.push(w.replace(/^["']|["']$/g, ''))
52  }
53  return files.length === 1 ? files[0]! : null
54}
55
56const kb = (n: number) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.round(n / 1024)} KB`)
57const ktok = (n: number) => (n >= 1000 ? `~${Math.round(n / 1000)}k` : `~${n}`)
58
59function advice(v: NonNullable<Verdict>, path: string, viaShell: boolean): string {
60  const what =
61    v.kind === 'lock'
62      ? 'a lockfile; look up one package with Grep (or npm ls / pip show / cargo tree) instead'
63      : v.kind === 'minified'
64        ? 'minified code or a source map; read the source file instead, or Grep for what you need'
65        : `larger than the Read Guard limit of ${limitKb} KB; use Grep to find the part you need, then Read with offset and limit`
66  return (
67    `Read Guard held ${viaShell ? 'this shell command' : 'this Read'}: ${path} is ${kb(v.size)} (${ktok(v.tokens)} tokens), ${what}. ` +
68    'If you really need the whole file, try the same call once more; it then goes through.'
69  )
70}
71
72async function resolve($: EngineInterface, file: string): Promise<string> {
73  if (/^([a-z]:)?[\\/]/i.test(file) || file.startsWith('~')) return file
74  const cwd = (await $.session.cwd()).replace(/\\/g, '/').replace(/\/$/, '')
75  return `${cwd}/${file.replace(/^\.\//, '')}`
76}
77
78async function check($: EngineInterface, path: string, viaShell: boolean): Promise<string | null> {
79  const key = path.replace(/\\/g, '/').toLowerCase()
80  if (held.has(key)) return null
81  const st = await $.fs.stat(path).catch(() => null)
82  if (!st || st.kind !== 'file') return null
83  const v = judge(path, st.size, limitKb * 1024)
84  if (!v) return null
85  const name = path.replace(/\\/g, '/').split('/').pop()
86  if (mode === 'warn') {
87    $.ui.toast(`📖 ${t('Lese-Wächter', 'Read Guard')}: ${name} ${kb(v.size)} (${ktok(v.tokens)} Tokens)`, { timeoutMs: 5000 })
88    return null
89  }
90  held.add(key)
91  $.ui.toast(`📖 ${t('Lese-Wächter hat angehalten', 'Read Guard held')}: ${name} ${kb(v.size)} (${ktok(v.tokens)} Tokens)`, { timeoutMs: 5000 })
92  return advice(v, path, viaShell)
93}
94
95export const register: Register = (on, options) => {
96  lang = options.language === 'de' ? 'de' : 'en'
97  mode = String(options.mode ?? 'block')
98  limitKb = options.limitKb === 'off' ? 0 : Number(options.limitKb ?? 50) || 50
99  shell = String(options.shell ?? 'on') !== 'off'
100
101  on('session.start', {}, async ($, e, next) => {
102    held.clear()
103    return next(e)
104  })
105
106  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
107    const path = String(e.file_path ?? '')
108    // Read's own allow_large (Claude Code 2.1.296) is Claude saying it means the whole file.
109    if (!path || e.offset != null || e.limit != null || (e as { allow_large?: unknown }).allow_large === true) return next(e)
110    const deny = await check($, path, false)
111    return deny ? { deny } : next(e)
112  })
113
114  for (const tool of ['Bash', 'PowerShell']) {
115    on('tool.call', { tool }, async ($, e, next) => {
116      if (!shell) return next(e)
117      const file = shellDump(String(e.command ?? ''))
118      if (!file) return next(e)
119      const deny = await check($, await resolve($, file), true)
120      return deny ? { deny } : next(e)
121    })
122  }
123}
124