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

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.
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.
See every Rostech mod, live, at rostech.app.
hooks/register.ts 72 lines1import { 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}
72types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'hands-off': { paths: string[] }
4 }
5}
6