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…

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.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install read-guard --marketplace LupusMalusDeviant/claude-mods
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
limitKb | 50 | Files above this size are held when read whole. off: only lockfiles, minified files and source maps. |
mode | block | block: hold the first try and tell Claude a better way. warn: only a notice. |
shell | on | on: also cat, type and Get-Content of a whole file. |
language | en | Language of the texts this mod shows and writes. |
MIT, see LICENSE.
hooks/entry.ts 9 lines1// 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}
9mods/read-guard/hooks/register.tsx 124 lines1// 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