Main is never the operating surface: a mod that keeps every write out of <project>/worktrees/main and sends changes through feature worktrees and pull requests.

A Claude Code mod that enforces the golden rule: main is never the operating surface. Every change is made in a dedicated worktree on its own branch and reaches main only through a pull request. Unlike an instruction file, a mod runs inside Claude Code on every tool call, for the main conversation and every subagent, so the rule cannot be skipped because an instruction file did not load.
A main worktree is a git working tree whose folder is named main, in practice <project>/worktrees/main. Projects without one are not governed, and everything outside a main worktree is free to change.
| Layer | What happens |
|---|---|
| Rule in context | The rule is added to every system prompt (personal account). On a Team seat, where Claude Code's built-in guard keeps mods out of the system prompt, a short form rides on each prompt and each subagent's prompt. |
| Edit, Write, NotebookEdit, EnterWorktree | Refused when the destination (symlinks followed, any letter case) is inside a main worktree or the git metadata of a repository that has one, with the git worktree add command to use instead. |
| Bash and Monitor | Every command runs through hooks/sandbox/run.py inside /usr/bin/sandbox-exec, which denies writes to main worktrees except the git metadata feature worktrees need (objects, non-main refs, other worktrees' folders, FETCH_HEAD). HEAD, index, config, hooks and the main ref stay denied, and the folders holding a main worktree cannot be moved. cd and exit status behave as usual. |
| Pushes | From a governed repository, git push to main in any spelling, gh pr merge --admin, and API writes to refs/heads/main are refused (by the bootstrap, which knows the real working directory). Repositories without a main worktree are left alone. This is a backstop: real enforcement is branch protection on the host. |
| MCP tools | A tool whose arguments name a main worktree is refused unless mcpReadTools in hooks/policy.json lists it as read-only. |
| Other plugins | Their $.fs.write into a main worktree is refused, and their $.process.run/spawn run inside the same sandbox. |
| Self-protection | The plugin's files, ~/.claude/hooks/golden-rule.sh, the Codex write guard, ~/.gitconfig, ~/.git-hooks, new hooks modules in skills folders, and the folders holding any of them cannot be written or moved. Edits that would switch the plugin off are refused by the file they would leave; a command that switches it off or uninstalls it (in ~/.claude, ~/.claude-work or CLAUDE_CONFIG_DIR) has that put back; /config rows about hooks or plugins are refused. |
| Tripwire | A command tied to a main worktree that names a program handing work outside the sandbox (ddev, docker, osascript, tmux, ...) is watched; a change in the main worktree, or a check that fails, quarantines it until /golden-rule clear, which only the person can run. An unreadable quarantine record refuses rather than clears. |
Every gate fails closed: a guard that throws or times out refuses the call.
/golden-rule shows what is guarded, recent refusals and any quarantine.
git -C <project>/worktrees/main fetch origin
git -C <project>/worktrees/main worktree add ../<topic> -b feature/<topic> --no-track origin/main
# work, commit, then
git push origin HEAD:feature/<topic>
gh pr create --head feature/<topic>
--no-track matters: tracking would be written into the main worktree's .git/config, which the sandbox refuses.
/usr/bin/sandbox-exec), /usr/bin/python3, Claude Code 2.1.287 or later.~/<folder>/<project>/worktrees/main, set in hooks/policy.json), and anywhere a command runs or names.osascript, tmux, open, launchctl, a container engine) are watched, not refused, and only when they name the program; one started indirectly, by a script, is not seen. Whether to refuse them outright is open (plan question A).--safe-mode, --bare, or a hooks worker that crashes three times runs without installed mods; the settings hook ~/.claude/hooks/golden-rule.sh stays as the backstop.claude plugin test . # hook decisions, mocked engine
zsh tests/e2e/run.zsh # the real sandbox against fixture repositories
zsh tests/live/run.zsh <out> # the mod in a real headless session (uses the model)
Plan and design record: ~/Tools/CLAUDE-PLUGINS/plans/2026-10-06-golden-rule-mod.md.
hooks/mod/register.ts 414 lines1// The golden rule mod: main is never the operating surface. Every gate fails closed (its .catch refuses);
2// the shell layer hands each command to hooks/sandbox/run.py, which runs it inside a write-denying
3// sandbox and refuses pushes to main from governed repositories.
4// Plan: ~/Tools/CLAUDE-PLUGINS/plans/2026-10-06-golden-rule-mod.md.
5
6import type { EngineInterface, Register } from 'claude-code'
7
8import {
9 type GitDirs, type Io, type Policy, expandHome, gitDirsOf, governedGitPath, isGuardPath, isIdentityPath, isSettingsFile,
10 mainWorktreeOf, normalize, pathLiterals, placed, relativeMainTokens,
11} from './paths'
12import { RULE, SECTION_ID, SHORT_RULE } from './rule'
13import { wrapArgv, wrapCommand } from './shell'
14
15const NAME = 'golden-rule'
16
17export type Refusal = { at: string; tool: string; what: string; why: string }
18type Settings = { enabledPlugins?: Record<string, unknown>; disableAllHooks?: unknown }
19type Registry = { plugins?: Record<string, unknown> }
20
21let policy: Policy | undefined
22let home = ''
23// Whether prompt.compose reached the system prompt in this load. On a Team seat the built-in guard skips
24// it, so the short rule rides on each prompt and on each subagent's prompt instead.
25let composed = false
26// The command each wrapped call must arrive with at the shell, by tool call id, for the final check.
27const expected = new Map<string, string>()
28// The guard's own paths as written and as resolved, and every known main worktree's git directories.
29let guardRoots: string[] = []
30let gitDirs: GitDirs[] = []
31
32function io($: EngineInterface): Io {
33 return {
34 // Only "not there" reads as absent. A link that exists but leads nowhere (or nowhere the engine can
35 // name) cannot be placed, so it throws, as does any other failure: the gate's .catch refuses.
36 realPath: path => $.fs.stat(path, { resolve: true }).then(
37 stat => {
38 if (stat.realPath === undefined) throw new Error(`${path} cannot be resolved (a link that leads nowhere)`)
39 return stat.realPath
40 },
41 (error: unknown) => {
42 if (/ENOENT|not found|no such/i.test(String(error))) return undefined
43 throw error
44 },
45 ),
46 exists: path => $.fs.exists(path),
47 }
48}
49
50async function setup($: EngineInterface): Promise<Policy> {
51 if (!policy) {
52 home = (await $.env.get('HOME')) ?? ''
53 if (!home) throw new Error('HOME is not set')
54 const rules = JSON.parse(await $.fs.read(`${$.plugin.root}/hooks/policy.json`)) as Policy
55 const written = [$.plugin.root, ...rules.guardFiles.map(file => normalize(expandHome(file, home)))]
56 const resolved = await Promise.all(written.map(path => io($).realPath(path)))
57 guardRoots = [...new Set([...written, ...resolved.filter((path): path is string => path !== undefined)])]
58 policy = rules
59 gitDirs = await knownGitDirs($, await guarded($))
60 }
61 return policy
62}
63
64const readText = ($: EngineInterface) => (path: string): Promise<string | undefined> =>
65 $.fs.stat(path).then(stat => (stat.kind === 'file' ? $.fs.read(path) : undefined), () => undefined)
66
67async function knownGitDirs($: EngineInterface, roots: readonly string[]): Promise<GitDirs[]> {
68 const out: GitDirs[] = []
69 for (const root of roots) out.push(await gitDirsOf(root, readText($), io($)))
70 return out
71}
72
73async function refuse($: EngineInterface, tool: string, what: string, why: string): Promise<{ deny: string }> {
74 const log = ((await $.store.get('refusals').catch(() => undefined)) as Refusal[] | undefined) ?? []
75 const at = new Date(await $.clock.now()).toISOString()
76 await $.store.set('refusals', [...log, { at, tool, what: what.slice(0, 200), why }].slice(-50)).catch(() => undefined)
77 $.ui.toast(`golden rule refused ${tool}: ${why.split('.')[0]}`)
78 return { deny: `golden-rule: ${why}` }
79}
80
81const worktreeHint = (root: string): string =>
82 `Make this change in a sibling worktree: git -C ${root} worktree add ../<topic> -b feature/<topic> --no-track origin/main`
83
84/** Why writing `text` (when known) to `path` is refused, or undefined when it is allowed. */
85async function judgeWrite($: EngineInterface, path: string, text?: string): Promise<string | undefined> {
86 const rules = await setup($)
87 const cwd = await $.session.cwd()
88 const spelled = normalize(expandHome(path, home), cwd)
89 if (isIdentityPath(spelled)) return `${path} names a file by its identity (/.vol), which the guard cannot place; use its real path.`
90 const real = await placed(path, cwd, home, io($))
91 if (isIdentityPath(real)) return `${path} lands on a file-identity path (${real}), which the guard cannot place; use its real path.`
92 const root = await mainWorktreeOf(real, io($))
93 if (root) return `${real} is in the main worktree ${root}, the reference, which is never written to. ${worktreeHint(root)}`
94 const owner = governedGitPath(real, gitDirs)
95 if (owner) return `${real} is git metadata of the main worktree ${owner.root}; only git itself writes there.`
96 if (isGuardPath([spelled, real], guardRoots, rules)) return `${real} belongs to the golden rule's guard, which a session never changes.`
97 if (text !== undefined && switchesOff(text, rules.pluginId) && (isSettingsFile(spelled) || isSettingsFile(real) || await isWatchedSettings($, real))) {
98 return `that change to ${real} would switch the golden rule guard off, which a session never does.`
99 }
100 return undefined
101}
102
103// ---- Settings and the plugin registry ---------------------------------------------------------------
104
105/** Whether a real path is where one of the watched settings files resolves to now (a settings file may be a
106 * link to a file named anything, and may be retargeted during the session). */
107async function isWatchedSettings($: EngineInterface, real: string): Promise<boolean> {
108 const { settings } = await watchedFiles($)
109 for (const path of settings) {
110 const resolved = await io($).realPath(path).catch(() => undefined)
111 if (resolved !== undefined && resolved.toLowerCase() === real.toLowerCase()) return true
112 }
113 return false
114}
115
116function parse(text: string | undefined): unknown {
117 if (text === undefined) return undefined
118 try {
119 return JSON.parse(text)
120 } catch {
121 return undefined
122 }
123}
124
125function disables(json: unknown, pluginId: string): boolean {
126 if (typeof json !== 'object' || json === null) return false
127 const settings = json as Settings
128 return settings.disableAllHooks === true || settings.enabledPlugins?.[pluginId] === false
129}
130
131/** Whether a settings file's proposed text switches the plugin off. Text that is not JSON is judged by
132 * its words, since Claude Code may still read a settings file a later write repairs. */
133function switchesOff(text: string, pluginId: string): boolean {
134 const json = parse(text)
135 if (json !== undefined) return disables(json, pluginId)
136 const id = pluginId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
137 return /"disableAllHooks"\s*:\s*true/.test(text) || new RegExp(`"${id}"\\s*:\\s*false`).test(text)
138}
139
140/** The text an Edit would leave in a file, from its current text. */
141function edited(current: string, oldString: string, newString: string, all: boolean): string {
142 return all ? current.split(oldString).join(newString) : current.replace(oldString, () => newString)
143}
144
145/** Every Claude configuration folder this session's settings and registry live in. */
146async function configDirs($: EngineInterface): Promise<string[]> {
147 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
148 return [...new Set([configured, `${home}/.claude`, `${home}/.claude-work`].filter((dir): dir is string => Boolean(dir)))]
149}
150
151async function watchedFiles($: EngineInterface): Promise<{ settings: string[]; registries: string[] }> {
152 const dirs = await configDirs($)
153 const cwd = await $.session.cwd()
154 return {
155 settings: [...dirs.flatMap(dir => [`${dir}/settings.json`, `${dir}/settings.local.json`]), `${cwd}/.claude/settings.json`, `${cwd}/.claude/settings.local.json`],
156 registries: dirs.map(dir => `${dir}/plugins/installed_plugins.json`),
157 }
158}
159
160async function snapshot($: EngineInterface): Promise<Map<string, string | undefined>> {
161 const { settings, registries } = await watchedFiles($)
162 const out = new Map<string, string | undefined>()
163 for (const path of [...settings, ...registries]) out.set(path, await $.fs.read(path).catch(() => undefined))
164 return out
165}
166
167/** Undoes a command's switching the guard off or uninstalling it: only the keys that do it. */
168async function restore($: EngineInterface, before: Map<string, string | undefined>): Promise<string[]> {
169 const rules = await setup($)
170 const { settings, registries } = await watchedFiles($)
171 const restored: string[] = []
172 const indentOf = (text: string): string => /\n( +)"/.exec(text)?.[1] ?? ' '
173 for (const path of settings) {
174 const now = await $.fs.read(path).catch(() => undefined)
175 const was = parse(before.get(path)) as Settings | undefined
176 const json = parse(now) as Settings | undefined
177 if (now === undefined || !json || !disables(json, rules.pluginId) || disables(was, rules.pluginId)) continue
178 if (json.enabledPlugins?.[rules.pluginId] === false) {
179 if (was?.enabledPlugins && rules.pluginId in was.enabledPlugins) json.enabledPlugins[rules.pluginId] = was.enabledPlugins[rules.pluginId]
180 else delete json.enabledPlugins[rules.pluginId]
181 }
182 if (json.disableAllHooks === true && was?.disableAllHooks !== true) delete json.disableAllHooks
183 await $.fs.write(path, `${JSON.stringify(json, null, indentOf(now))}\n`)
184 restored.push(path)
185 }
186 for (const path of registries) {
187 const was = parse(before.get(path)) as Registry | undefined
188 const now = await $.fs.read(path).catch(() => undefined)
189 const json = (parse(now) ?? {}) as Registry
190 const entry = was?.plugins?.[rules.pluginId]
191 if (entry === undefined || json.plugins?.[rules.pluginId] !== undefined) continue
192 json.plugins = { ...json.plugins, [rules.pluginId]: entry }
193 await $.fs.write(path, `${JSON.stringify(json, null, indentOf(now ?? before.get(path) ?? ''))}\n`)
194 restored.push(path)
195 }
196 return restored
197}
198
199// ---- Strings inside a tool's arguments ---------------------------------------------------------------
200
201const bytes = (text: string): number => new TextEncoder().encode(text).length
202
203/** Whether a string is within macOS's limits for a path (PATH_MAX 1,024, NAME_MAX 255, no NUL). */
204export function couldBePath(text: string): boolean {
205 return text.length > 0 && !text.includes('\0') && bytes(text) <= 1024 && text.split('/').every(name => bytes(name) <= 255)
206}
207
208function strings(value: unknown): string[] {
209 if (typeof value === 'string') return [value]
210 if (Array.isArray(value)) return value.flatMap(strings)
211 if (typeof value === 'object' && value !== null) return Object.values(value).flatMap(strings)
212 return []
213}
214
215// ---- Status and the /golden-rule command -------------------------------------------------------------
216
217/** Every main worktree in the person's project folders: each container's children with worktrees/main. */
218async function guarded($: EngineInterface): Promise<string[]> {
219 const rules = await setup($)
220 const containers: string[] = []
221 for (const container of rules.containers) {
222 const path = expandHome(container, home)
223 if (!path.endsWith('/*')) containers.push(path)
224 else for (const entry of await $.fs.list(path.slice(0, -2)).catch(() => [])) if (entry.kind === 'dir') containers.push(`${path.slice(0, -2)}/${entry.name}`)
225 }
226 const roots: string[] = []
227 for (const folder of containers) {
228 for (const entry of await $.fs.list(folder).catch(() => [])) {
229 const root = `${folder}/${entry.name}/worktrees/main`
230 if (entry.kind === 'dir' && await $.fs.exists(`${root}/.git`).catch(() => false)) roots.push(root)
231 }
232 }
233 return roots
234}
235
236type Quarantine = Record<string, { at: string; path: string; command: string }>
237
238async function quarantine($: EngineInterface): Promise<Quarantine | 'unreadable'> {
239 const rules = await setup($)
240 const text = await $.fs.read(expandHome(rules.quarantineFile, home)).catch(() => undefined)
241 if (text === undefined) return {}
242 const json = parse(text)
243 return typeof json === 'object' && json !== null ? json as Quarantine : 'unreadable'
244}
245
246async function showStatus($: EngineInterface): Promise<void> {
247 const held = await quarantine($)
248 const count = (await guarded($)).length
249 if (held === 'unreadable') $.ui.status('golden rule: quarantine record unreadable, commands in main worktrees refused; run /golden-rule')
250 else if (Object.keys(held).length > 0) $.ui.status(`golden rule: ${Object.keys(held).length} main worktree quarantined, run /golden-rule`)
251 else $.ui.status(`golden rule: ${count} main worktrees guarded${composed ? '' : ' (rule on each prompt)'}`)
252}
253
254async function report($: EngineInterface): Promise<string> {
255 const roots = await guarded($)
256 const held = await quarantine($)
257 const refusals = (((await $.store.get('refusals').catch(() => undefined)) as Refusal[] | undefined) ?? []).slice(-10)
258 const heldLines = held === 'unreadable'
259 ? ['The quarantine record is unreadable: commands touching main worktrees are refused until /golden-rule clear.']
260 : Object.keys(held).length > 0
261 ? ['Quarantined (a change was observed; run /golden-rule clear once you have looked):',
262 ...Object.entries(held).map(([root, entry]) => ` ${root}: ${entry.path} at ${entry.at}, during: ${entry.command}`)]
263 : ['Nothing quarantined.']
264 return [
265 `Golden rule: main is never the operating surface. ${roots.length} main worktrees guarded:`,
266 ` ${roots.map(root => root.split('/').slice(-3, -2)[0]).join(', ')}`,
267 ...heldLines,
268 refusals.length > 0 ? 'Recent refusals:' : 'No refusals yet.',
269 ...refusals.map(entry => ` ${entry.at} ${entry.tool}: ${entry.why.split('.')[0]}`),
270 `Rule as the model reads it: ${composed ? 'system prompt' : 'a short form on each prompt (the system prompt is not open to mods on this account)'}.`,
271 ].join('\n')
272}
273
274export const register: Register = on => {
275 on('session.start', async ($, e, next) => {
276 await setup($)
277 await showStatus($).catch(() => undefined)
278 await $.command.register({ name: 'golden-rule', description: 'Golden rule: what is guarded, recent refusals, quarantines', argumentHint: '[clear]' }).catch(() => undefined)
279 return next(e)
280 })
281
282 on('command.run', { command: 'golden-rule' }, async ($, e) => {
283 if (e.args.trim() !== 'clear') return { text: await report($) }
284 if (e.origin.kind !== 'composer') return { text: 'golden-rule: only Chris, typing /golden-rule clear himself, can lift a quarantine.' }
285 const rules = await setup($)
286 await $.fs.write(expandHome(rules.quarantineFile, home), '{}\n')
287 await showStatus($)
288 return { text: 'golden-rule: quarantine cleared.' }
289 })
290
291 // ---- Layer 1: the rule is always in context ----
292 on('prompt.compose', async ($, e, next) => {
293 const composedPrompt = await next(e)
294 composed = true
295 if (composedPrompt.sections.some(section => section.id === SECTION_ID)) return composedPrompt
296 return { sections: [...composedPrompt.sections, { id: SECTION_ID, text: RULE, scope: 'session' as const }] }
297 })
298
299 on('prompt.submit', async ($, e, next) =>
300 composed ? next(e) : next({ ...e, context: [...(e.context ?? []), SHORT_RULE] }),
301 ).catch(($, e, next) => next(e))
302
303 // ---- Layer 2: structured tools ----
304 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
305 let path: string
306 let text: string | undefined
307 if (e.tool === 'NotebookEdit') path = e.notebook_path
308 else if (e.tool === 'Write') [path, text] = [e.file_path, e.content]
309 else {
310 path = e.file_path
311 const current = await $.fs.read(path).catch(() => undefined)
312 text = current === undefined ? undefined : edited(current, e.old_string, e.new_string, e.replace_all === true)
313 }
314 const why = await judgeWrite($, path, text)
315 return why ? refuse($, e.tool, path, why) : next(e)
316 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'golden-rule: the edit guard failed, so this edit was not made.' }))
317
318 on('tool.call', { tool: 'EnterWorktree' }, async ($, e, next) => {
319 if (e.path) {
320 const root = await mainWorktreeOf(await placed(e.path, await $.session.cwd(), home, io($)), io($))
321 if (root) return refuse($, e.tool, e.path, `${root} is the main worktree, the reference; a session never works inside it. ${worktreeHint(root)}`)
322 }
323 return next(e)
324 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'golden-rule: the worktree guard failed, so the session stayed where it is.' }))
325
326 // A subagent's prompt carries the short rule where the system prompt cannot (a Team seat).
327 on('tool.call', { tool: 'Agent' }, async ($, e, next) =>
328 composed || typeof e.prompt !== 'string' ? next(e) : next({ ...e, prompt: `${SHORT_RULE}\n\n${e.prompt}` }),
329 ).catch(($, e, next) => next(e))
330
331 // ---- Layer 3: every command runs sandboxed ----
332 on('tool.call', { tool: ['Bash', 'Monitor'] }, async ($, e, next) => {
333 if (typeof e.command !== 'string') return next(e)
334 const rules = await setup($)
335 const before = await snapshot($)
336 const wrapped = wrapCommand($.plugin.root, e.command)
337 if (e.tool_use_id) expected.set(e.tool_use_id, wrapped)
338 const result = await next({ ...e, command: wrapped })
339 if (e.tool_use_id) expected.delete(e.tool_use_id)
340 let restored: string[]
341 try {
342 restored = await restore($, before)
343 } catch (error) {
344 await refuse($, e.tool, e.command, `the guard could not check whether that command switched it off (${String(error).slice(0, 120)}).`)
345 return { deny: 'golden-rule: the command ran, but the guard could not confirm it is still switched on; tell Chris.' }
346 }
347 if (restored.length === 0) return result
348 await refuse($, e.tool, e.command, `that command switched the golden rule guard off in ${restored.join(', ')}; the setting was put back.`)
349 return { deny: `golden-rule: that command switched the guard off (${rules.pluginId}); the setting was put back. A session never disables the guard.` }
350 }).catch(($, e, next) => (next.called ? next(e) : { deny: `golden-rule: the shell guard failed (${next.error.kind}), so this command was not run.` }))
351
352 // The last word on what runs, beneath every plugin's rewrite: exactly the wrapped command, or nothing
353 // (skipped on a Team seat by the built-in guard).
354 on('classic.PreToolUse', async ($, e, next) => {
355 if ((e.tool === 'Bash' || e.tool === 'Monitor') && typeof e.command === 'string') {
356 const want = e.tool_use_id ? expected.get(e.tool_use_id) : undefined
357 if (want === undefined ? !e.command.startsWith('__gr=$(/usr/bin/mktemp -t golden-rule)') : e.command !== want) {
358 return { deny: 'golden-rule: this command was changed on its way to the shell after the guard wrapped it, so it was not run.' }
359 }
360 }
361 return next(e)
362 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'golden-rule: the final command check failed, so this command was not run.' }))
363
364 // ---- Layer 4: MCP tools that name a main worktree ----
365 // Every string argument is judged as a path where it could be one: whole (a path with spaces), each path
366 // token inside it, and a bare relative name against the session's folder. A destination split across
367 // arguments or computed by the server cannot be seen; that residual is documented.
368 on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
369 const tool = String(e.tool)
370 const rules = await setup($)
371 if (rules.mcpReadTools.some(pattern => new RegExp(pattern).test(tool))) return next(e)
372 // A session working inside a main worktree: any relative destination an MCP server takes lands there.
373 const inside = await mainWorktreeOf(await placed('.', await $.session.cwd(), home, io($)), io($))
374 if (inside) return refuse($, tool, inside, `this session's folder is inside the main worktree ${inside}, so this MCP call could write there. ${worktreeHint(inside)}`)
375 const texts = strings(Object.fromEntries(Object.entries(e).filter(([key]) => key !== 'tool' && key !== 'tool_use_id' && key !== 'agentId')))
376 // Every string that could name a file is placed as a path, links followed, and judged by where it
377 // lands: a destination reached through a link into main is caught, and ordinary text lands nowhere
378 // protected. A string past the system's path limits (1,024 bytes, 255 per name) cannot be a
379 // destination and is not placed, so long content never fails the call.
380 const whole = texts.filter(couldBePath)
381 for (const path of [...new Set([...whole, ...texts.flatMap(pathLiterals), ...texts.flatMap(relativeMainTokens)])].filter(couldBePath)) {
382 const why = await judgeWrite($, path)
383 if (why) return refuse($, tool, path, `${why} (MCP tools are refused for main worktrees unless listed as read-only in policy.json)`)
384 }
385 return next(e)
386 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'golden-rule: the MCP guard failed, so this call was not made.' }))
387
388 // ---- Layer 5: other plugins' files and processes ----
389 on('fs.write', async ($, e, next) => {
390 if (next.origin.plugin === NAME) return next(e)
391 const why = await judgeWrite($, e.path, e.text)
392 return why ? { deny: `golden-rule (refusing ${next.origin.plugin}): ${why}` } : next(e)
393 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'golden-rule: the file guard failed, so this write was not made.' }))
394
395 on('process.run', async ($, e, next) =>
396 next.origin.plugin === NAME ? next(e) : next({ ...e, argv: wrapArgv($.plugin.root, e.argv) }),
397 ).catch(($, e, next) => (next.called ? next(e) : { deny: 'golden-rule: the process guard failed, so this program was not started.' }))
398
399 on('process.spawn', async function* ($, e, next) {
400 return yield* next(next.origin.plugin === NAME ? e : { ...e, argv: wrapArgv($.plugin.root, e.argv) })
401 }).catch(async function* ($, e, next) {
402 if (next.called) return yield* next(e)
403 return { deny: 'golden-rule: the process guard failed, so this program was not started.' }
404 })
405
406 // ---- Settings rows that would switch hooks or plugins off ----
407 // The person's own changes in the menu pass; a plugin's are refused for hook and plugin rows.
408 on('config.set', async ($, e, next) =>
409 e.origin.kind !== 'composer' && /hook|plugin|golden/i.test(e.key)
410 ? { deny: 'golden-rule: a plugin never changes hook or plugin settings; Chris can change this in /config himself.' }
411 : next(e),
412 ).catch(($, e, next) => (next.called ? next(e) : { deny: 'golden-rule: the settings guard failed, so this setting was not changed.' }))
413}
414hooks/mod/paths.ts 146 lines1// Where a path lands, and whether that is a main worktree, its git metadata, or one of the guard's own
2// files. Pure functions over a small file-system view, so tests can run them without the engine.
3
4export type Policy = {
5 containers: string[]
6 guardFiles: string[]
7 guardPatterns: string[]
8 mcpReadTools: string[]
9 pluginId: string
10 quarantineFile: string
11}
12
13/** The file-system answers the decisions need. `realPath` is undefined only for a path that is not there. */
14export type Io = {
15 realPath: (path: string) => Promise<string | undefined>
16 exists: (path: string) => Promise<boolean>
17}
18
19export function expandHome(path: string, home: string): string {
20 if (path === '~') return home
21 return path.startsWith('~/') ? `${home}${path.slice(1)}` : path
22}
23
24/** Collapses `.`, `..` and repeated slashes without touching the disk. Relative paths join `cwd`. */
25export function normalize(path: string, cwd = '/'): string {
26 const parts: string[] = []
27 for (const part of (path.startsWith('/') ? path : `${cwd}/${path}`).split('/')) {
28 if (part === '' || part === '.') continue
29 if (part === '..') parts.pop()
30 else parts.push(part)
31 }
32 return `/${parts.join('/')}`
33}
34
35export const parentOf = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/'
36export const baseOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
37// macOS volumes are case-insensitive by default: `MAIN` and `.GIT` name the same folders.
38const named = (path: string, name: string): boolean => baseOf(path).toLowerCase() === name
39
40/** Every directory from `path` up to the root, nearest first. */
41export function ancestorsOf(path: string): string[] {
42 const out: string[] = []
43 for (let at = path; ; at = parentOf(at)) {
44 out.push(at)
45 if (at === '/') return out
46 }
47}
48
49/**
50 * Where a path a tool names would land, resolved the way the file system walks it: component by
51 * component, each existing one replaced by its real path before the next `..` is applied (so
52 * `link/../x` lands beside the link's target, not beside the link). Past the first component that does
53 * not exist the rest is joined as written. A file-system error other than "not there" propagates, so the
54 * calling gate's `.catch` refuses instead of judging a path it could not place.
55 */
56export async function placed(path: string, cwd: string, home: string, io: Io): Promise<string> {
57 const expanded = expandHome(path, home)
58 let at = expanded.startsWith('/') ? '/' : (await io.realPath(cwd)) ?? normalize(cwd)
59 let exists = true
60 for (const part of expanded.split('/')) {
61 if (part === '' || part === '.') continue
62 if (part === '..') {
63 at = parentOf(at)
64 continue
65 }
66 const next = at === '/' ? `/${part}` : `${at}/${part}`
67 const real = exists ? await io.realPath(next) : undefined
68 if (real === undefined) exists = false
69 at = real ?? next
70 }
71 return at
72}
73
74/** A spelling that reaches a file by its identity (`/.vol/<device>/<inode>`), which no path check can judge. */
75export const isIdentityPath = (path: string): boolean => /^\/\.vol(\/|$)/i.test(path)
76
77/**
78 * The main worktree a real path is inside, if any: the nearest ancestor that is a working-tree root named
79 * `main` (any case). A repository nested inside a main worktree stays inside it; a feature worktree nested
80 * inside a project root that merely has `main` checked out is not inside one, since that root is not
81 * named `main`.
82 */
83export async function mainWorktreeOf(real: string, io: Io): Promise<string | undefined> {
84 for (const dir of ancestorsOf(real)) {
85 if (named(dir, 'main') && await io.exists(`${dir}/.git`)) return dir
86 }
87 return undefined
88}
89
90/** A main worktree's git directories as git lays them out: its own (`gitdir`) and the shared one. */
91export type GitDirs = { root: string; gitdir: string; common: string }
92
93/**
94 * Where a main worktree keeps its git metadata: a `.git` folder, or a `.git` file naming a directory
95 * elsewhere (a linked worktree under any administrative id, a separate git dir, a bare repository's
96 * worktree), whose `commondir` names the shared directory.
97 */
98export async function gitDirsOf(root: string, read: (path: string) => Promise<string | undefined>, io: Io): Promise<GitDirs> {
99 const marker = `${root}/.git`
100 const text = await read(marker)
101 if (text === undefined) {
102 const real = (await io.realPath(marker)) ?? marker
103 return { root, gitdir: real, common: real }
104 }
105 const pointer = /^gitdir: (.+)$/m.exec(text)?.[1]?.trim()
106 if (!pointer) throw new Error(`cannot read ${marker}`)
107 const gitdir = (await io.realPath(normalize(pointer, root))) ?? normalize(pointer, root)
108 const commondir = (await read(`${gitdir}/commondir`))?.trim()
109 const common = commondir ? (await io.realPath(normalize(commondir, gitdir))) ?? normalize(commondir, gitdir) : gitdir
110 return { root, gitdir, common }
111}
112
113/** Which main worktree's git metadata a real path is inside, if any. Git writes there itself; a session's
114 * Edit or Write never needs to. */
115export function governedGitPath(real: string, dirs: readonly GitDirs[]): GitDirs | undefined {
116 const path = real.toLowerCase()
117 return dirs.find(({ gitdir, common }) => [gitdir, common].some(dir => path === dir.toLowerCase() || path.startsWith(`${dir.toLowerCase()}/`)))
118}
119
120const lower = (path: string): string => path.toLowerCase()
121
122/** Whether a path is one of the guard's own files, or a place that would load a new hooks module.
123 * `roots` holds the guard's paths both as written and resolved, and callers pass both spellings. */
124export function isGuardPath(paths: readonly string[], roots: readonly string[], policy: Policy): boolean {
125 const guarded = roots.map(lower)
126 return paths.some(candidate => {
127 const path = lower(candidate)
128 return guarded.some(root => path === root || path.startsWith(`${root}/`))
129 || policy.guardPatterns.some(pattern => new RegExp(pattern, 'i').test(candidate))
130 })
131}
132
133/** Settings files that could switch a plugin off, in any Claude configuration folder (`~/.claude-work` too). */
134export const isSettingsFile = (real: string): boolean => /\/\.claude[^/]*\/settings(\.local)?\.json$/i.test(real)
135
136/** Absolute, home-relative and relative path tokens in a command or a tool's string arguments. */
137export function pathLiterals(text: string): string[] {
138 return [...text.matchAll(/(?:^|[\s"'`=:(])((?:~|\/)[^\s"'`;|&()<>]*)/g)].map(match => match[1]).filter(path => path.length > 1)
139}
140
141/** Relative tokens that name a `main` folder (`../main/README.md`), resolved later against the working directory. */
142export function relativeMainTokens(text: string): string[] {
143 return [...text.matchAll(/[^\s"'`;|&()<>]+/g)].map(match => match[0])
144 .filter(token => /(^|\/)main(\/|$)/i.test(token) && !/^[~/-]/.test(token))
145}
146hooks/mod/rule.ts 33 lines1// The golden rule as the model reads it, written from Chris's decisions (plan section 5), not older wording.
2
3export const SECTION_ID = 'golden-rule'
4
5export const RULE = `# The golden rule (enforced by the golden-rule mod)
6
7Main is never the operating surface. Every change is made in a dedicated worktree on its own branch and
8reaches \`main\` only through a pull request.
9
10- A main worktree is a git working tree whose folder is named \`main\`, in practice
11 \`<project>/worktrees/main\`. It is the reference: read it freely, never write to it, its files or its
12 git state (HEAD, index, config, hooks, the main branch). Commands that write nothing there are fine.
13- Only \`main\` is covered. Projects without a main worktree are not governed. Everything outside a main
14 worktree is yours to change.
15- Before the first edit, check where it lands. If it is a main worktree, create a sibling worktree
16 without asking:
17 \`git -C <project>/worktrees/main fetch origin\`, then
18 \`git -C <project>/worktrees/main worktree add ../<topic> -b feature/<topic> --no-track origin/main\`
19 (use the remote that hosts pull requests). Work, commit and push there:
20 \`git push origin HEAD:feature/<topic>\`, then \`gh pr create --head feature/<topic>\`.
21- Work for a governed project happens in a sibling worktree of its main worktree, never in a clone made
22 somewhere else.
23- When the guard refuses something, move the work to a worktree. Never route around it, rename a branch
24 or folder, or try to disable or edit the guard.
25- If a write reached a main worktree anyway, report it to Chris and leave it: remediation is his.
26- Interpreting the rule's letter to dodge its spirit is itself the violation. A plan that seems to need
27 main is a wrong plan, not an exception request.`
28
29export const SHORT_RULE = `Golden rule (enforced): never write to a main worktree (<project>/worktrees/main), its files or git state.
30Make changes in a sibling worktree: git -C <project>/worktrees/main worktree add ../<topic> -b feature/<topic> --no-track origin/main.
31Changes reach main only by pull request. When refused, move to a worktree; never route around or disable the guard.
32If a write reached main, report it to Chris and leave it.`
33hooks/mod/shell.ts 44 lines1// The shell side of the guard: wrapping a command so the bootstrap runs it inside the sandbox. The
2// bootstrap (hooks/sandbox/run.py) also refuses pushes to main, since only it knows the shell's real
3// working directory and so whether the repository is governed.
4
5function b64(text: string): string {
6 let binary = ''
7 for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte)
8 return btoa(binary)
9}
10
11const quote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
12
13/** A heredoc delimiter that is no line of the command, so the body ends where the command does. */
14export function delimiterFor(command: string): string {
15 const lines = new Set(command.split('\n'))
16 let delimiter = 'GOLDEN_RULE_EOF'
17 for (let n = 1; lines.has(delimiter); n++) delimiter = `GOLDEN_RULE_EOF_${n}`
18 return delimiter
19}
20
21/**
22 * The command the Bash tool actually runs. The bootstrap computes the sandbox profile from the shell's
23 * real working directory, runs the model's command inside it, and writes the child's final directory
24 * to a file, so `cd` carries over to the next call; the exit status passes through.
25 *
26 * The command travels verbatim as the body of a quoted heredoc: the outer shell expands nothing in
27 * it, and every other plugin's PreToolUse hook, which runs after this rewrite, reads the command as
28 * the model wrote it rather than an encoding of it.
29 */
30export function wrapCommand(pluginRoot: string, command: string): string {
31 const delimiter = delimiterFor(command)
32 return [
33 `__gr=$(/usr/bin/mktemp -t golden-rule); /usr/bin/python3 -I ${quote(`${pluginRoot}/hooks/sandbox/run.py`)} --stdin --state "$__gr" <<'${delimiter}'`,
34 command,
35 delimiter,
36 ['__s=$?', '[ -s "$__gr" ] && cd "$(/bin/cat "$__gr")" 2>/dev/null', '/bin/rm -f "$__gr"', '(exit $__s)'].join('; '),
37 ].join('\n')
38}
39
40/** Another plugin's `$.process.run`/`spawn` argument vector, run through the same bootstrap. */
41export function wrapArgv(pluginRoot: string, argv: readonly string[]): string[] {
42 return ['/usr/bin/python3', '-I', `${pluginRoot}/hooks/sandbox/run.py`, '--argv', b64(JSON.stringify(argv))]
43}
44