SLOPSHOPPER

golden-rule

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.

newguardcommandtoaststatusprompt
v0.1.1UNLICENSEDupdated 2026-10-08cosmicdreams/claude-plugins/golden-rule
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · golden-rule
› 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 ⏺ Edit(/work/app/src/auth.ts) ⎿ Denied by golden-rule: golden-rule: the edit guard failed, so this edit was not made. ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

golden-rule

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.

What it guards

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.

LayerWhat happens
Rule in contextThe 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, EnterWorktreeRefused 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 MonitorEvery 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.
PushesFrom 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 toolsA tool whose arguments name a main worktree is refused unless mcpReadTools in hooks/policy.json lists it as read-only.
Other pluginsTheir $.fs.write into a main worktree is refused, and their $.process.run/spawn run inside the same sandbox.
Self-protectionThe 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.
TripwireA 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.

Working with it

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.

Requirements and limits

  • macOS (/usr/bin/sandbox-exec), /usr/bin/python3, Claude Code 2.1.287 or later.
  • Main worktrees are found two levels under the home folder (~/<folder>/<project>/worktrees/main, set in hooks/policy.json), and anywhere a command runs or names.
  • Commands that hand work to another process (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).
  • Built for full-access sessions. In auto mode the command rewrite makes Claude Code deny Bash calls.
  • --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.
  • A command costs about 0.2 s more. A watched command in a large main worktree costs a few seconds for the scan.

Tests

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.

Source 4 files
hooks/mod/register.ts 414 lines
1// 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}
414
hooks/mod/paths.ts 146 lines
1// 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}
146
hooks/mod/rule.ts 33 lines
1// 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.`
33
hooks/mod/shell.ts 44 lines
1// 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