SLOPSHOPPER

read-fence

Stops Claude from opening your skill, rule and plugin folders file by file while you have the fence switched on. Skills still run normally.

newguardcommandprompt
v0.1.0MITupdated 2026-10-13varunmoka7/read-fence
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · read-fence
› 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 › /read-fence ⎿ read-fence: read-fence is off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

read-fence

A Claude Code mod that stops Claude from opening your skill, rule and plugin folders file by file, while you have the fence switched on.

Why you might want it

Claude already sees the name and a short description of every skill you have installed. Opening a skill's folder to read its files adds a lot more text to the conversation, and that text is sent again with every later message. The fence keeps Claude from doing that by accident.

Example: you ask Claude to "look at what skills I have". Without the fence it may open dozens of skill files. With the fence on, it is told to use the list it already has, and to run a skill with the Skill tool when it needs one.

Install

Paste these two lines into a terminal (not into Claude):

claude plugin marketplace add varunmoka7/read-fence
claude plugin install read-fence@read-fence

Needs Claude Code 2.1.287 or newer (mods are on by default from that version). Works on macOS and Linux. Windows paths are not handled, so the fence will not block anything there.

How to use

One command, inside Claude Code:

  • /read-fence on switches the fence on.
  • /read-fence off switches it off.
  • /read-fence status (or just /read-fence) tells you whether it is on.

The fence starts off.

While it is on, Claude's Read, Glob, Grep and Bash tools are refused when they point at:

  • the skills, rules and plugins/cache folders inside your Claude config folder (~/.claude, or the folder named by CLAUDE_CONFIG_DIR)
  • the .claude/skills and .claude/rules folders of the project you are working in

These are always allowed, even with the fence on:

  • the folder of a skill Claude has just run
  • any file called CLAUDE.md
  • the memory folders inside your Claude config folder

What it touches

  • Network: none.
  • Files read: one small file, read-fence.json in your Claude config folder.
  • Files written: the same file, only when you run /read-fence on or /read-fence off.
  • Model calls: none. It adds no cost of its own.
  • Off by default. You switch it on yourself.

Turn it off / uninstall

Switch the fence off with /read-fence off, or remove the mod:

claude plugin uninstall read-fence@read-fence

You can delete read-fence.json from your Claude config folder afterwards.

Known limits

  • It reads the paths written in the tool call. A Bash command that builds a folder name from other commands or variables (other than $HOME) or uses a path relative to the current folder can get past it.
  • Folders are checked as written and after symbolic links are followed, but a Bash command that changes folder first and then uses a short path is not caught.
  • The switch is one file per Claude config folder. If you use more than one config folder, switch each one separately.
  • If the fence cannot work something out, it lets the call through rather than blocking your work.

License

MIT

Source 1 files
hooks/read-fence.mjs 121 lines
1// Read Fence: while on, Read, Glob, Grep and Bash may not touch the folders that
2// already enter the context as a listing (skills, rules, plugin cache). Off by
3// default. The switch lives in read-fence.json inside the Claude config folder
4// (CLAUDE_CONFIG_DIR, or ~/.claude when that is not set).
5//
6// Exempt even when on: the skill the model just invoked (its folder, learnt from
7// skill.prompt), any CLAUDE.md, and the memory folders under the config folder.
8
9const SETTINGS = "read-fence.json";
10const BLOCKED_IN_CONFIG = ["plugins/cache", "skills", "rules"];
11const BLOCKED_IN_PROJECT = [".claude/skills", ".claude/rules"];
12
13// Folders of skills invoked this session (absolute, from the skill prompt's base dir line)
14const invoked = new Set();
15
16export function register(on) {
17  on("session.start", async ($, e, next) => {
18    await $.command.register({
19      name: "read-fence",
20      description: "Turn the read fence on or off, or show its state: on, off or status",
21      argumentHint: "on|off|status",
22    });
23    return next(e);
24  });
25
26  on("command.run", ($, e, next) => runCommand($, e, next).catch(() => next(e)));
27
28  on("skill.prompt", async ($, e, next) => {
29    const m = /Base directory for this skill:\s*(\S+)/.exec(String(e.text ?? ""));
30    if (m) invoked.add(m[1].replace(/\/+$/, ""));
31    return next(e);
32  });
33
34  // The fence is a convenience: if it cannot decide, the call goes through.
35  on("tool.call", { tool: ["Read", "Glob", "Grep", "Bash"] }, ($, e, next) => guard($, e, next).catch(() => next(e)));
36}
37
38async function runCommand($, e, next) {
39  if (e.command !== "read-fence") return next(e);
40  const arg = String(e.args ?? "").trim().toLowerCase();
41  if (arg === "on" || arg === "off") {
42    try {
43      await setOn($, arg === "on");
44    } catch {
45      return { text: `read-fence could not save its setting, so it is still ${(await isOn($)) ? "on" : "off"}` };
46    }
47    return { text: `read-fence ${arg}` };
48  }
49  return { text: `read-fence is ${(await isOn($)) ? "on" : "off"}` };
50}
51
52async function guard($, e, next) {
53  if (!(await isOn($))) return next(e);
54  const home = await $.env.get("HOME");
55  const config = await configDir($);
56  const root = await $.session.root();
57  const cwd = await $.session.cwd();
58  for (const p of await touched($, e, cwd, home)) {
59    const why = await blocked($, p, config, root);
60    if (why) {
61      return {
62        deny: `Read Fence refused ${e.tool} on ${p}: ${why}. The fence is on (/read-fence off lifts it). Skill folders enter the context as name and description already; invoke a skill with the Skill tool instead of reading its folder.`,
63      };
64    }
65  }
66  return next(e);
67}
68
69async function configDir($) {
70  const set = await $.env.get("CLAUDE_CONFIG_DIR");
71  const dir = set || `${await $.env.get("HOME")}/.claude`;
72  return dir.replace(/\/+$/, "");
73}
74
75async function isOn($) {
76  try {
77    return JSON.parse(await $.fs.read(`${await configDir($)}/${SETTINGS}`)).on === true;
78  } catch {
79    return false;
80  }
81}
82
83async function setOn($, on) {
84  await $.fs.write(`${await configDir($)}/${SETTINGS}`, JSON.stringify({ on }) + "\n");
85}
86
87/** The absolute paths a call reaches, as spelled. */
88async function touched($, e, cwd, home) {
89  const abs = (p) => (p.startsWith("/") ? p : `${cwd}/${p}`);
90  if (e.tool === "Read") return [abs(String(e.file_path ?? ""))];
91  if (e.tool === "Glob" || e.tool === "Grep") {
92    const base = abs(String(e.path ?? "."));
93    const out = [base];
94    const pat = String(e.pattern ?? "");
95    // A Glob pattern can reach a folder by itself, absolute or relative to the search path.
96    if (e.tool === "Glob" && pat) out.push(pat.startsWith("/") ? pat.split("*")[0] : `${base}/${pat.split("*")[0]}`);
97    return out;
98  }
99  // Bash: every absolute or ~ path spelled in the command line
100  const command = String(e.command ?? "").replace(/\$\{HOME\}|\$HOME/g, home);
101  const found = command.match(/(?:~|\/)[\w./@-]+/g) ?? [];
102  return found.map((p) => (p.startsWith("~") ? home + p.slice(1) : p));
103}
104
105/** Why `path` is fenced, or null. Spelled path decides exemptions; spelled and real path decide blocks. */
106async function blocked($, path, config, root) {
107  const under = (p, dir) => p === dir || p.startsWith(dir + "/");
108  if (/(^|\/)CLAUDE\.md$/.test(path)) return null;
109  if (path === `${config}/${SETTINGS}`) return null;
110  if (/\/projects\/[^/]+\/memory(\/|$)/.test(path) && under(path, config)) return null;
111  for (const dir of invoked) if (under(path, dir)) return null;
112
113  const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath;
114  for (const p of real && real !== path ? [path, real] : [path]) {
115    for (const b of BLOCKED_IN_CONFIG) if (under(p, `${config}/${b}`)) return `${b} is fenced`;
116    for (const b of BLOCKED_IN_PROJECT) if (under(p, `${root}/${b}`)) return `${b} is fenced`;
117    for (const dir of invoked) if (under(p, dir)) return null;
118  }
119  return null;
120}
121