SLOPSHOPPER

Hands-Off

Files you mark as yours: Claude cannot edit them until you hand them back.

guardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hands-off
› 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 › /hands-off ⎿ hands-off: Nothing is marked. /hands-off <path> marks a file or folder. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Hands-Off

Files you mark as yours. Claude cannot edit them until you hand them back, so a file you are working on by hand stays exactly as you left it.

  • /hands-off notes/plan.md marks a file. A folder covers everything inside it.
  • /hands-off list shows what is marked.
  • /hands-off release notes/plan.md hands one back. /hands-off release all hands back everything.

It guards Claude's Edit, Write and NotebookEdit tools: an edit to a marked path is refused, with a message telling Claude to ask you first. The marks are kept across sessions on your own machine, in Claude Code's plugin store. It sends nothing anywhere.

Why this exists

I edit my own files by hand, in Notepad++.

I direct Claude Code all day. I do not write the code.

I run Claude like a workshop, and Claude is the foreman. On 15 August 2026, half in character, I told the foreman this, at 1:23 in the afternoon:

"Next time i come in here and 2 minutes later some idiot AI is [messing] with MY files in any way that i dont approve then you habndel it"

Nothing had been wrecked yet. That was the point. I wanted it fixed before it happened.

The rule it made: when I say a file is mine, no Claude writes to it. Not a typo. Not a reformat. Not because the change is obviously right. And I release it. Nobody else does.

The first version was a flag and a warning. A shell script kept the list, and a hook printed it above every turn. It could not stop a write. It only made a write deliberate instead of an accident.

This mod is the version that stops it.

Tested with Claude Code 2.1.287.

More mods

See every Rostech mod, live, at rostech.app.

Source 2 files
hooks/register.ts 72 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// Paths you have marked as yours. $.store keeps them across sessions.
5const paths = atom({ plugin: 'hands-off', key: 'paths' } as const, [] as string[])
6
7function slashes(p: string): string {
8  return p.split(String.fromCharCode(92)).join("/")
9}
10
11// One spelling for every path: forward slashes, no trailing slash, lower case,
12// and relative paths read from the project root.
13async function norm($: EngineInterface, p: string): Promise<string> {
14  let s = slashes(p.trim()).replace(/[/]+$/, '')
15  const isAbsolute = /^[a-zA-Z]:/.test(s) || s.startsWith('/')
16  if (!isAbsolute) s = `${slashes(await $.session.root())}/${s.startsWith('./') ? s.slice(2) : s}`
17  return s.toLowerCase()
18}
19
20async function save($: EngineInterface, next: string[]) {
21  await $.store.set('paths', next)
22  await update($, paths, () => next)
23}
24
25// A file is held when it is a marked path or sits inside a marked folder.
26function holder(file: string, held: string[]): string | undefined {
27  return held.find(h => file === h || file.startsWith(h + '/'))
28}
29
30export const register: Register = on => {
31  on('session.start', async ($, e, next) => {
32    await $.command.register({ name: 'hands-off', description: 'Mark files Claude may not edit. /hands-off <path>, /hands-off list, /hands-off release <path>.' })
33    const kept = await $.store.get('paths')
34    await update($, paths, () => (Array.isArray(kept) ? (kept as string[]) : []))
35    return next(e)
36  })
37
38  on('tool.call', async ($, e, next) => {
39    const target = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
40    if (target) {
41      const held = holder(await norm($, target), await read($, paths))
42      if (held) return { deny: `hands-off: ${target} is marked hands-off by the user. Ask them to release it with /hands-off release before editing it.` }
43    }
44    return next(e)
45  })
46
47  on('command.run', { command: 'hands-off' }, async ($, e) => {
48    const [verb, ...rest] = e.args.trim().split(/\s+/)
49    const arg = rest.join(' ')
50    const held = await read($, paths)
51    if (!verb || verb === 'list') {
52      return { text: held.length ? `✋ Hands off:\n${held.join('\n')}` : 'Nothing is marked. /hands-off <path> marks a file or folder.' }
53    }
54    if (verb === 'release') {
55      if (arg === 'all') { await save($, []); return { text: 'Everything released.' } }
56      const p = await norm($, arg).catch(() => '')
57      // Forgiving: "notes md" or "notesmd" still finds notes.md, by letters and digits alone.
58      const loose = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '')
59      const want = loose(arg)
60      const match = held.includes(p) ? p : held.find(h => loose(h.split('/').pop() ?? '') === want || loose(h).endsWith(want))
61      if (!match) {
62        return { text: held.length ? `${arg} is not marked. Marked now:\n${held.join('\n')}` : `${arg} is not marked. Nothing is marked.` }
63      }
64      await save($, held.filter(h => h !== match))
65      return { text: `Released: ${match}` }
66    }
67    const p = await norm($, e.args)
68    if (!held.includes(p)) await save($, [...held, p])
69    return { text: `✋ Hands off: ${e.args.trim()}. Claude cannot edit it until you release it.` }
70  })
71}
72
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'hands-off': { paths: string[] }
4  }
5}
6