SLOPSHOPPER

read-only-mode

/readonly on turns the session into look-but-don't-touch: file edits are refused and only read-only shell commands (ls, cat, git status/log/diff, grep...) run…

newguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · read-only-mode
› 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 › /readonly ⎿ read-only-mode: Read-only mode OFF. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

read-only-mode

Look but don't touch: explore a codebase with edits disabled.

/readonly on refuses every file edit and every shell command that is not clearly read-only (ls, cat, grep, git status/log/diff, find without -delete...). Redirects, command substitution and unknown commands are refused. Great for onboarding to unfamiliar code or inspecting a production checkout.

Install

/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install read-only-mode@mod-garage

Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/read-only-mode

Use it

  • /readonly on to enable, /readonly off to disable, /readonly status to check.

What you'll see

Illustrative output.

Enable

> /readonly on
Read-only mode ON: edits and mutating commands are refused.

Model tries to edit

Read-only mode is on (the user ran /readonly on). Do not edit files; describe the change instead.

Status line

🔒 read-only

Notes

  • It is a guardrail on the model's tool calls, not a sandbox: it does not stop you from running commands yourself.
Source 2 files
hooks/register.ts 32 lines
1import type { Register } from 'claude-code'
2import { EDIT_TOOLS, isReadOnlyCommand } from './lib'
3
4export const register: Register = on => {
5  let isOn = false
6
7  on('session.start', async ($, e, next) => {
8    await $.command.register({ name: 'readonly', description: 'Look-but-don\'t-touch mode: /readonly on | off | status', argumentHint: 'on|off|status' })
9    return next(e)
10  })
11
12  on('command.run', { command: 'readonly' }, ($, e) => {
13    const arg = (e.args ?? '').trim().toLowerCase()
14    if (arg === 'on') isOn = true
15    else if (arg === 'off') isOn = false
16    else if (arg !== 'status' && arg !== '') return { text: 'Usage: /readonly on | off | status' }
17    $.ui.status(isOn ? '🔒 read-only' : undefined)
18    return { text: isOn ? 'Read-only mode ON: edits and mutating commands are refused.' : 'Read-only mode OFF.' }
19  })
20
21  on('tool.call', ($, e, next) => {
22    if (!isOn) return next(e)
23    if (EDIT_TOOLS.includes(e.tool)) {
24      return { deny: 'Read-only mode is on (the user ran /readonly on). Do not edit files; describe the change instead.' }
25    }
26    if (e.tool === 'Bash' && !isReadOnlyCommand(String(e.command ?? ''))) {
27      return { deny: 'Read-only mode is on. Only read-only commands (ls, cat, grep, git status/log/diff...) may run. Ask the user to run /readonly off if this command is needed.' }
28    }
29    return next(e)
30  }).catch(($, e, next) => ({ deny: `read-only-mode failed (${next.error.kind}); call not run.` }))
31}
32
hooks/lib.ts 33 lines
1const READ_CMDS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'pwd', 'echo', 'grep', 'rg', 'which', 'file', 'stat', 'du', 'df', 'tree', 'diff', 'sort', 'uniq', 'cut', 'date', 'whoami', 'env', 'printenv', 'basename', 'dirname', 'realpath'])
2const GIT_READ = new Set(['status', 'log', 'diff', 'show', 'branch', 'remote', 'blame', 'ls-files', 'rev-parse', 'describe', 'shortlog', 'tag', 'config', 'stash'])
3const GIT_MUTATING_FLAGS = /(^|\s)(-d|-D|-m|-M|--delete|--set|--unset|--add|drop|pop|apply|clear)(\s|$)/
4
5/** True only when every segment of the command is a known read-only command with no redirect. */
6export function isReadOnlyCommand(command: string): boolean {
7  if (/[>`]|\$\(|<\(/.test(command)) return false
8  const segments = command.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean)
9  if (segments.length === 0) return false
10  return segments.every(seg => {
11    const words = seg.split(/\s+/)
12    const cmd = words[0]
13    if (cmd === 'git') {
14      const sub = words.slice(1).find(w => !w.startsWith('-'))
15      if (!sub || !GIT_READ.has(sub)) return false
16      if (['branch', 'tag', 'config', 'stash', 'remote'].includes(sub)) {
17        const rest = words.slice(words.indexOf(sub) + 1).join(' ')
18        // listing is fine; creating, deleting or setting is not
19        if (sub === 'stash') return /^(list|show)\b/.test(rest)
20        if (sub === 'config') return /^(--get|--list|-l)\b/.test(rest)
21        if (sub === 'remote') return rest === '' || /^(-v|show)\b/.test(rest)
22        return rest === '' || !(GIT_MUTATING_FLAGS.test(rest) || /^[^-]/.test(rest)) || /^(-a|-r|-v|--list|--show-current)\b/.test(rest)
23      }
24      return true
25    }
26    if (cmd === 'find') return !/(^|\s)(-delete|-exec|-execdir|-ok)\b/.test(seg)
27    if (cmd === 'sed') return !/\s-i/.test(seg)
28    return READ_CMDS.has(cmd)
29  })
30}
31
32export const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
33