SLOPSHOPPER

cortex-guard

Two rules no settings hook enforces, refused at the tool call: wiki pages and their generated ADR mirrors are written only through the Cortex wiki tool, and…

newguardtoastprocess
v0.1.0no licenseupdated 2026-10-07cdeust/claude-mods/mods/cortex-guard
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code mods for the ai-architect.tools harness, one concern per mod, state shared through dependencies:

ModOwnsDepends on
cortex-guardrefusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/nothing
cortex-wiki/wiki <wiki/**.md> to PDFnothing
zetetic-geniusstate: the request grade (task class → effort), the genius patterns and skills it matchesnothing
zetetic-autopilotcontext, policy: effort ladder, model routing, pressure, lean resultszetetic-genius
cortex-cockpitstats, ledger, tally, stages, hygiene; the /cortex pane draws the restthe three above
harness-fleetfleet: the owner's plugins, installed vs offered version, open PRs with CI, open issues (a defect is taken and fixed, a feature request goes to the owner); /fleet. Reads only (gh pr list, gh issue list, git remote): every outward action is a button the owner presses, which puts a prompt in front of the modelguard, genius, autopilot

Developing

Each mod is validated, tested and type-checked on its own:

cd mods/<mod> && claude plugin validate . && claude plugin test && npx -y -p typescript tsc -p .

tsc needs the engine to have loaded the mod once (it lays .claude-plugin/types/). For hot reload in a session, link the mod into that session's ~/.claude/dev-mods/<session>/ folder; the engine watches the folder a link names.

Installing

.claude-plugin/marketplace.json lists each mod as "source": "./mods/<mod>", so the repository is the marketplace. From another machine:

/plugin install <mod> --marketplace cdeust/claude-mods

y adds the marketplace, then the user scope. On this machine a folder marketplace reads the mods from the checkout, with no copy: claude plugin marketplace add <this folder>, then /plugin install <mod> and /reload-plugins after an edit.

Source 3 files
hooks/register.ts 87 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Refusal } from '../types'
5import { type CallInput, judgeCall, judgePath, worktreeAddPath } from './guard'
6
7const REFUSALS_CAP = 200 // source: bounds $.state size; a viewer shows the last rows only
8
9const refusals = atom({ plugin: 'cortex-guard', key: 'refusals' } as const, [] as Refusal[])
10
11// Module state: where the repo is and which script registers a worktree. A hot reload runs
12// session.start again, so both are set afresh.
13let repo = ''
14let hygieneScript = ''
15
16// Where a path lands once links and `..` are resolved; the spelling when it does not exist yet.
17async function realPathOf($: EngineInterface, path: string): Promise<string> {
18  try {
19    const stat = await $.fs.stat(path, { resolve: true })
20    return (stat as { realPath?: string }).realPath ?? path
21  } catch {
22    return path
23  }
24}
25
26// Pairs with the worktree rule: a worktree that exists is registered, as CLAUDE.md prescribes
27// (the registered cleanup procedure, 2026-09-26).
28async function registerWorktree($: EngineInterface, path: string): Promise<void> {
29  if (hygieneScript === '') return
30  const real = await realPathOf($, path)
31  const ran = await $.process.run([
32    'python3',
33    hygieneScript,
34    '--host',
35    'claude',
36    '--session',
37    await $.session.id(),
38    'register-worktree',
39    '--repo',
40    repo,
41    '--path',
42    real,
43  ])
44  $.ui.toast(
45    ran.exitCode === 0
46      ? `worktree registered: ${real}`
47      : `worktree NOT registered (${ran.stderr.slice(0, 80)})`,
48  )
49}
50
51export const register: Register = (on, options) => {
52  hygieneScript = String(options.hygiene_script ?? '')
53
54  on('session.start', async ($, e, next) => {
55    repo = (await $.session.repo())?.root ?? e.cwd
56
57    return next(e)
58  })
59
60  // Fail closed: a guard that throws refuses.
61  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
62    const tool = String(e.tool)
63    const input = e as CallInput
64    let verdict = judgeCall(tool, input)
65    if (verdict.allow && input.file_path !== undefined)
66      verdict = judgePath(await realPathOf($, input.file_path))
67    if (verdict.allow) {
68      const ran = await next(e)
69      const added = tool === 'Bash' && !ran.isError ? worktreeAddPath(input.command ?? '') : undefined
70      if (added !== undefined) void registerWorktree($, added)
71      return ran
72    }
73    const target = input.file_path ?? input.notebook_path ?? (input.command ?? '').slice(0, 60)
74    const at = await $.clock.now()
75    await update($, refusals, (list) =>
76      [...list, { at, tool, rule: verdict.rule, target }].slice(-REFUSALS_CAP),
77    )
78    $.ui.toast(`refused ${tool}: ${verdict.rule}`)
79
80    return { deny: `cortex-guard (${verdict.rule}): ${verdict.reason}` }
81  }).catch(($, e, next) =>
82    next.called
83      ? next(e)
84      : { deny: `cortex-guard: its guard failed on ${String(e.tool)}, so the call was refused.` },
85  )
86}
87
hooks/guard.ts 113 lines
1// Pure verdicts for the two rules no settings hook enforces: wiki pages are written only
2// through the wiki tool, and every git worktree lives inside the repo.
3
4// source: the generated mirrors carry "Read-only mirror: edit the canonical wiki page"
5// (docs/adr/ADR-1060-*.md header); the canonical pages live under wiki/ (wiki/README.md).
6const MIRROR = /(^|\/)docs\/adr\/ADR-\d+[^/]*\.md$/
7const CANONICAL = /(^|\/)wiki\/(adr|specs|lessons)\/.+\.md$/
8
9// source: ~/.claude/CLAUDE.md rule 2 and Cortex CLAUDE.md: <repo>/.claude/worktrees/<name>/,
10// .Codex/worktrees/<name>/ for Codex.
11export const WORKTREE_ROOTS = ['/.claude/worktrees/', '/.Codex/worktrees/'] as const
12
13export type Verdict = { allow: true } | { allow: false; rule: string; reason: string }
14
15const ALLOW: Verdict = { allow: true }
16
17export const WIKI_RULE = 'wiki-write-only'
18export const WORKTREE_RULE = 'worktree-inside-repo'
19
20const refuseWiki = (path: string, why: string): Verdict => ({
21  allow: false,
22  rule: WIKI_RULE,
23  reason: `${path}: ${why} Use the Cortex wiki_write tool (wiki_adr for a decision); wiki_reindex regenerates the mirror.`,
24})
25
26export const judgePath = (path: string): Verdict => {
27  if (MIRROR.test(path)) return refuseWiki(path, 'a generated read-only mirror.')
28  if (CANONICAL.test(path))
29    return refuseWiki(path, 'a canonical wiki page, written only through the wiki tool.')
30  return ALLOW
31}
32
33const WRITES_FILES = /(^|[\s;&|(])(sed\s+-i|tee|cp|mv|rm|truncate|dd|perl\s+-pi)\b|>>?\s*\S/
34
35const tokensOf = (command: string): string[] =>
36  command.split(/\s+/).map((t) => t.replace(/^['"]|['"]$/g, ''))
37
38// Best effort: a shell command that names a protected path and writes.
39export const judgeShellWiki = (command: string): Verdict => {
40  if (!WRITES_FILES.test(command)) return ALLOW
41  for (const path of tokensOf(command)) {
42    const verdict = judgePath(path)
43    if (!verdict.allow) return verdict
44  }
45  return ALLOW
46}
47
48export const isInsideWorktreeRoot = (path: string): boolean =>
49  WORKTREE_ROOTS.some((root) => path.includes(root))
50
51// source: `git [-C <path>] [-c <name>=<value>] [--git-dir=<path>|--git-dir <path>] ...
52// <command>` (git(1) SYNOPSIS); these global options take a value when given separately.
53const GIT_VALUED_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
54
55// Index of the subcommand after `git` at `at`, skipping git's own global options.
56const gitSubcommandIndex = (t: string[], at: number): number => {
57  let i = at + 1
58  while (i < t.length && (t[i] ?? '').startsWith('-')) {
59    i += GIT_VALUED_OPTIONS.has(t[i] ?? '') ? 2 : 1
60  }
61  return i
62}
63
64// `git [global options] worktree add [options] <path> [<commit-ish>]`: the path is the
65// first token after `add` that is not an option and not an option's value (-b/-B/--orphan).
66export const worktreeAddPath = (command: string): string | undefined => {
67  const t = tokensOf(command)
68  let at = -1
69  for (let i = 0; i < t.length && at < 0; i++) {
70    if (t[i] !== 'git') continue
71    const sub = gitSubcommandIndex(t, i)
72    if (t[sub] === 'worktree' && t[sub + 1] === 'add') at = sub
73  }
74  if (at < 0) return undefined
75  const args = t.slice(at + 2)
76  for (let i = 0; i < args.length; i++) {
77    const a = args[i] ?? ''
78    if (a === '-b' || a === '-B' || a === '--orphan') {
79      i++
80      continue
81    }
82    if (a.startsWith('-')) continue
83    return a
84  }
85  return undefined
86}
87
88export const judgeShellWorktree = (command: string): Verdict => {
89  const path = worktreeAddPath(command)
90  if (path === undefined || isInsideWorktreeRoot(path)) return ALLOW
91  return {
92    allow: false,
93    rule: WORKTREE_RULE,
94    reason: `git worktree add ${path}: a worktree lives at <repo>/.claude/worktrees/<name>/ (.Codex/worktrees/ for Codex), never outside the repo.`,
95  }
96}
97
98export const judgeShell = (command: string): Verdict => {
99  const wiki = judgeShellWiki(command)
100  return wiki.allow ? judgeShellWorktree(command) : wiki
101}
102
103export type CallInput = { file_path?: string; command?: string; notebook_path?: string }
104
105export const judgeCall = (tool: string, input: CallInput): Verdict => {
106  if ((tool === 'Edit' || tool === 'Write') && input.file_path !== undefined)
107    return judgePath(input.file_path)
108  if (tool === 'NotebookEdit' && input.notebook_path !== undefined)
109    return judgePath(input.notebook_path)
110  if (tool === 'Bash' && input.command !== undefined) return judgeShell(input.command)
111  return ALLOW
112}
113
types/index.d.ts 16 lines
1// One refused call: when, which tool, which rule, and what it aimed at.
2export type Refusal = {
3  at: number
4  tool: string
5  rule: string
6  target: string
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'cortex-guard': {
12      refusals: Refusal[]
13    }
14  }
15}
16