SLOPSHOPPER

ep0ch-repo

The ep0ch repository's own rules for agents working in it: guardrails that refuse pkill, git stash, gh pr merge -d, bun test --parallel on float-2 and a…

newguardtoastprocesstimeragents
v0.1.0no licenseupdated 2026-10-23float-ritual-stack/ep0ch/.claude/skills/ep0ch-repo
A shopper browsing a rack in a slop shop
README

ep0ch-repo: the repository's own Claude mod (PIE-842)

A Claude Code mod (a plugin of function hooks) for sessions in this repository. It lives in the project's .claude/skills/, so Claude Code loads it, as ep0ch-repo@skills-dir, for any session started in a checkout or worktree of ep0ch and nowhere else. There is nothing to install or enable. It is not packages/claude-mod: that one serves every folder bound to an outline, and this one holds rules about this repository.

Guardrails

A Bash call that breaks one of AGENTS.md's hard rules is refused. The refusal gives the reason and the command to run instead (hooks/guards.ts):

RefusedRun instead
pkill, killall (any pattern)check pstree -p <pid>, then kill <pid>; scripts/agent-env <name> --clean
git stash (list and show still run)a wip: commit on your branch
gh pr merge … -d / --delete-branchgh pr merge <n> --merge
bun test --parallel on float-2scripts/agent-env <name> --test -- timeout 900 bun test <files>; scripts/box-test
a whole-suite bun test / bun run test… (no test file named) on float-box or float-2, outside scripts/agent-envscripts/box-test <package>; a few files: scripts/agent-env <name> --test -- timeout 900 bun test <files>
gh pr merge into main when the PR changes PROTOCOL to anything but main's plus onethe number to use, and the rebase
  • The command is read the way a shell splits it (hooks/shell.ts): ;, && and pipes split it, wrappers such as timeout, env, sudo, scripts/agent-env … -- and bash -c '…' are looked through, and quoted text and heredoc bodies are ignored. A commit message that mentions pkill is not a pkill.
  • The PROTOCOL check asks gh for each merged PR's head and reads packages/outline-core/src/protocol.ts at that commit, at main, and where the branch left main. Only a branch that changed it is held to main's plus one; a branch merely behind a bump on main merges. A GRAMMAR-only bump is accepted, even though it edits the same file. A slice merging into its wave/… branch is let through: it takes its number when the wave merges. When gh can't answer within 10s, the merge goes on.

Scratch cleanup never stops for a prompt

Claude Code's dangerous-command check stops some rm -rf shapes (a glob over the working folder, a path ending in ..) even under bypass permissions, and a builder's prompt surfaces in the deployer's session and waits there. Two parts (hooks/scratch.ts, scripts/scratch-clean):

  • Agents don't write rm -rf: they run scripts/scratch-clean <dir>…, which removes only folders under /tmp/claude-<uid>/, /tmp/ep0ch-agent-<uid>/ and ~/.agent-env/ (and ~/wt/<name> worktrees through git worktree remove), and refuses anything else with why. A script's own rm is not a command the check sees.
  • The mod answers "allow" to a permission request for a Bash command that is nothing but rm -r of plain paths that all resolve (realpath -m: links followed, .. taken) under those scratch roots. A variable, glob, other command, root folder, home or repository path is left to the normal prompt.

The freshness sweep

  • Who: a session whose nearest .ep0ch says agent = "loki" (the live checkout's does), or that has EP0CH_AGENT=loki. Builders' worktrees have no .ep0ch, so they never sweep. EP0CH_FRESHNESS=on or off overrides this.
  • When: after 3 deploys or merges (ep0ch install --apply, gh pr merge that ran), or after 10 turns that wrote to the outline, counted in the main loop since the last sweep (hooks/freshness.ts).
  • What: an agent type of this mod's own, ep0ch-repo:freshness-sweep. It is never offered to the model, runs on Sonnet in the background, and gets .claude/freshness.md as its brief.
  • Read-only by the engine:
  • its tools are Read, Grep, Glob, ToolSearch, Bash and the outline read tools;
  • each Bash command goes through scripts/read-only-bash.ts, a PreToolUse hook on the agent, which refuses anything not on a list of read commands, and any redirection to a file.
  • It is not a fork of the session. The engine runs a plugin's fork under the plugin's own origin, so the fork's calls skip the plugin's hooks, and a live run showed a fork writing a file the mod meant to refuse.
  • The answer: the agent's answer comes back as a prompt from the ep0ch-repo plugin. The main thread checks the findings and makes the updates as itself. A toast says when a sweep starts, or why it couldn't.

Package rules for commands

Claude Code loads a path-scoped rule (.claude/rules/*.md) when an agent reads a file its paths: match. It never loads one when the agent runs a command, so an agent that runs a test door from Bash without opening a door file would miss door.md. The mod closes that gap (hooks/rules.ts):

  • What it looks at: the paths a Bash command names, and the folder the shell is in. Relative paths are resolved from that folder. An absolute path to another checkout counts by its tail (~/projects/ep0ch/scripts/agent-env → scripts/agent-env). Wrappers count too (scripts/agent-env … --, bash -c '…').
  • What decides: each rule's own paths:. A new rule file is picked up with no change here, and a narrow rule (outlines-by-name.md) fires only for its own files.
  • How it arrives: the first command that touches a rule's files carries the rule's text after its result, as context the agent reads. This happens once per loop: the main thread and each subagent get it once each, and again after a compaction or /clear. A refused command carries nothing.

Develop

claude plugin validate .claude/skills/ep0ch-repo
claude plugin test .claude/skills/ep0ch-repo     # under scripts/agent-env <name> --test -- on float-2

These tests aren't in a workspace package, so neither the root bun run test nor CI runs them: run them when you change the mod. An interactive session in the repository reloads the mod when its files change. claude -p can't show the sweep: a headless run ends with its turn, before the agent could start.

Source 7 files
hooks/register.ts 250 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { pathsIn, type Rule, ruleContext, ruleOf, rulesFor } from './rules'
4import { guardOf, type Merge, mergesIn, protocolOf, protocolRefusal } from './guards'
5import { absolute, contentsOf, scratchRemovals, underScratch } from './scratch'
6import { agentOfEp0ch, type Counts, dueOf, SWEEP_AFTER_CHANGES, SWEEP_AFTER_TURNS, isDeploy, isMerge, isOutlineWrite, reportOf, SWEEP_SYSTEM, SWEEP_TOOLS, sweepBrief, sweepsFor } from './freshness'
7
8const PROTOCOL_PATH = 'packages/outline-core/src/protocol.ts'
9/** The sweep's agent type, `ep0ch-repo:<name>`: registered by this mod, offered to no model. */
10const SWEEP_AGENT = 'freshness-sweep'
11const LOOKUP_MS = 10_000
12
13/** This machine's name, asked once (the --parallel rule holds on the shared machine only). */
14let hostname: Promise<string> | undefined
15/** Whether this session sweeps, read once. */
16let sweeps: Promise<boolean> | undefined
17/** Deploys and merges, and turns that wrote to the outline, since the last sweep. */
18const counts: Counts = { changes: 0, turns: 0 }
19let wroteThisTurn = false
20/** The sweep running now (its agent id), and whether one is being started. */
21let sweepId: string | null = null
22/** When it started (the session clock): one that never answers stops blocking the next after SWEEP_STALE_MS. */
23let sweepAt = 0
24const SWEEP_STALE_MS = 30 * 60_000
25let starting = false
26/** The path-scoped rules (`.claude/rules`), read once; and which are in each loop's context (`<agent id>|<file>`). */
27let rules: Promise<Rule[]> | undefined
28const attached = new Set<string>()
29
30function hostnameOf($: EngineInterface): Promise<string> {
31  return (hostname ??= $.process.run(['hostname'], { timeoutMs: 3000 }).then(r => r.stdout.trim(), () => ''))
32}
33
34/** Whether this session sweeps: EP0CH_FRESHNESS, else the nearest `.ep0ch`'s agent (or EP0CH_AGENT) is loki. */
35function sweepsHere($: EngineInterface): Promise<boolean> {
36  return (sweeps ??= (async () => {
37    const [setting, envAgent, root] = await Promise.all([$.env.get('EP0CH_FRESHNESS'), $.env.get('EP0CH_AGENT'), $.session.root()])
38    let agent: string | null = null
39    for (let dir = root; dir; dir = dir.slice(0, dir.lastIndexOf('/'))) {
40      const text = await $.fs.read(`${dir}/.ep0ch`).then(t => String(t), () => null)
41      if (text !== null) { agent = agentOfEp0ch(text); break }
42    }
43    return sweepsFor(setting, agent ?? (envAgent?.trim() || null))
44  })().catch(() => false))
45}
46
47/** Why a merge's PROTOCOL is wrong (see protocolRefusal), or null: right, untouched, not into main, or not known in time. */
48async function protocolCheck($: EngineInterface, merge: Merge): Promise<string | null> {
49  const cwd = await $.session.cwd()
50  const gh = (args: string[]) => $.process.run(['gh', ...args], { cwd, timeoutMs: LOOKUP_MS })
51    .then(r => r.exitCode === 0 ? r.stdout : null, () => null)
52  const view = await gh(['pr', 'view', ...(merge.pr ? [merge.pr] : []), ...(merge.repo ? ['--repo', merge.repo] : []), '--json', 'headRefOid,baseRefName'])
53  if (!view) return null
54  const { headRefOid, baseRefName } = JSON.parse(view) as { headRefOid?: string; baseRefName?: string }
55  // Only a PR into main: a slice merging into its wave branch takes its number at the wave's merge.
56  if (!headRefOid || baseRefName !== 'main') return null
57  const repo = merge.repo ?? '{owner}/{repo}'
58  const at = (ref: string) => gh(['api', '-H', 'Accept: application/vnd.github.raw', `repos/${repo}/contents/${PROTOCOL_PATH}?ref=${ref}`])
59  // Compare the PROTOCOL value with merge-base: a GRAMMAR-only edit to this file is not a wire bump.
60  const compared = await gh(['api', `repos/${repo}/compare/main...${headRefOid}`, '--jq', '.merge_base_commit.sha'])
61  const mergeBase = compared?.trim()
62  if (!mergeBase) return null
63  const [branch, main, base] = await Promise.all([at(headRefOid), at('main'), at(mergeBase)])
64  const [b, m, o] = [branch, main, base].map(text => text === null ? null : protocolOf(text))
65  return b == null || m == null || o == null ? null : protocolRefusal(m, b, o, merge.pr)
66}
67
68/** The first merge in `command` whose PROTOCOL is wrong, said; null when none is. */
69async function protocolChecks($: EngineInterface, command: string): Promise<string | null> {
70  for (const merge of mergesIn(command)) {
71    const why = await protocolCheck($, merge)
72    if (why) return why
73  }
74  return null
75}
76
77/**
78 * Starts the sweep when this session sweeps: an agent type of this mod's own, read-only by the engine's rules (its
79 * tools are read tools and Bash, each Bash command through scripts/read-only-bash.ts), given the brief from
80 * `.claude/freshness.md`. Not a fork of the session: a plugin's fork runs under the plugin's own origin, so its calls
81 * skip this mod's hooks, and nothing here could refuse its writes. Never throws.
82 */
83async function startSweep($: EngineInterface): Promise<void> {
84  try {
85    if (!(await sweepsHere($))) {
86      // Not the sweeper's session: nothing to count toward.
87      counts.changes = 0
88      counts.turns = 0
89      return
90    }
91    // The file beside the mod: .claude/freshness.md, the mod being .claude/skills/ep0ch-repo.
92    const freshness = String(await $.fs.read(`${$.plugin.root}/../../freshness.md`))
93    const { agent } = await $.agent.register({
94      name: SWEEP_AGENT,
95      description: "Checks, read-only, what .claude/freshness.md lists as stale; started by the ep0ch-repo mod, never by hand.",
96      prompt: SWEEP_SYSTEM,
97      tools: SWEEP_TOOLS,
98      model: 'sonnet',
99      background: true,
100      hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: `bun ${JSON.stringify(`${$.plugin.root}/scripts/read-only-bash.ts`)}` }] }] },
101    })
102    const taken = { ...counts }
103    const why = `It runs now because this session made ${taken.changes} deploys or merges and ${taken.turns} turns that wrote to the outline since the last sweep.`
104    const spawned = await $.agent.spawn({ subagentType: agent, description: 'freshness sweep', prompt: sweepBrief(freshness, why) })
105    if ('deny' in spawned && spawned.deny) throw Error(spawned.deny)
106    if (!spawned.agentId) throw Error('no agent was started')
107    sweepId = spawned.agentId
108    sweepAt = await $.clock.now()
109    // What came in while it started counts toward the next sweep.
110    counts.changes -= taken.changes
111    counts.turns -= taken.turns
112    $.ui.toast('Freshness sweep: a read-only agent is checking .claude/freshness.md; its findings come back here.', { timeoutMs: 6000 })
113  } catch (error) {
114    // No brief, or the agent was refused: said, and tried again after the next change (not the next turn).
115    counts.changes = Math.min(counts.changes, SWEEP_AFTER_CHANGES - 1)
116    counts.turns = Math.min(counts.turns, SWEEP_AFTER_TURNS - 1)
117    $.ui.toast(`Freshness sweep couldn't start: ${error instanceof Error ? error.message : String(error)}`, { timeoutMs: 8000 })
118  } finally {
119    starting = false
120  }
121}
122
123/** The rule files beside the mod (`.claude/rules`, the mod being `.claude/skills/ep0ch-repo`), read once. */
124function rulesOf($: EngineInterface): Promise<Rule[]> {
125  return (rules ??= (async () => {
126    const dir = `${$.plugin.root}/../../rules`
127    const entries = await $.fs.list(dir)
128    const read = await Promise.all(entries.filter(f => f.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name))
129      .map(async f => ruleOf(`.claude/rules/${f.name}`, String(await $.fs.read(`${dir}/${f.name}`)))))
130    return read.filter((r): r is Rule => r !== null)
131  })().catch(() => []))
132}
133
134/**
135 * The rules a Bash command touches that this loop hasn't been given yet (once each, again after a compaction or
136 * /clear): their text, for the command's result to carry. Path-scoped rules load only when a file is read.
137 */
138async function rulesToAttach($: EngineInterface, command: string, agentId: string | undefined): Promise<string[]> {
139  const [all, cwd, root] = await Promise.all([rulesOf($), $.session.cwd(), $.session.root()])
140  const loop = agentId ?? ''
141  return rulesFor(pathsIn(command, cwd, root), all)
142    .filter(rule => !attached.has(`${loop}|${rule.file}`))
143    .map(rule => { attached.add(`${loop}|${rule.file}`); return ruleContext(rule) })
144}
145
146/**
147 * The ep0ch repository's own mod (PIE-842), loaded for sessions in this repository only (it lives in the project's
148 * `.claude/skills/`):
149 * - guardrails: a Bash call that breaks one of the repository's hard rules is refused with the reason and the command
150 *   to run instead (hooks/guards.ts);
151 * - the freshness sweep: in the deployer's sessions, after a few deploys or merges, a read-only background agent checks
152 *   what `.claude/freshness.md` lists as stale and hands its findings to the main thread (hooks/freshness.ts).
153 */
154export const register: Register = (on) => {
155  // A registration is one session (a reload, a test case): nothing carries over from the last one.
156  hostname = undefined
157  rules = undefined
158  attached.clear()
159  sweeps = undefined
160  counts.changes = 0
161  counts.turns = 0
162  wroteThisTurn = false
163  sweepId = null
164  sweepAt = 0
165  starting = false
166  // The sweep's type is the mod's own: never offered to the model.
167  on('agent.offer', { agent: `ep0ch-repo:${SWEEP_AGENT}` }, () => ({ isOffered: false }))
168
169  // An outline write marks the main loop's turn as one that changed things.
170  on('tool.call', async ($, e, next) => {
171    const result = await next(e)
172    if (e.agentId === undefined && isOutlineWrite(e.tool) && !('deny' in result && result.deny) && !result.isError) wroteThisTurn = true
173    return result
174  })
175
176  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
177    const command = e.command
178    const why = guardOf(command, { hostname: await hostnameOf($) }) ?? await protocolChecks($, command)
179    if (why) return { deny: why }
180    const result = await next(e)
181    if (result.deny !== undefined) return result
182    if (e.agentId === undefined && !result.isError && (isDeploy(command) || isMerge(command))) counts.changes++
183    const context = await rulesToAttach($, command, e.agentId).catch(() => [])
184    return context.length ? { ...result, context: [...(result.context ?? []), ...context] } : result
185  }).catch(($, e, next) => {
186    if (next.called) return next(e)
187    // The hook itself failed (a lookup): the rules that need nothing outside the command still hold.
188    const why = guardOf(e.command, { hostname: '' })
189    return why ? { deny: why } : next(e)
190  })
191
192  // A cleanup of scratch folders is never stopped for a permission prompt (hooks/scratch.ts): only a command that is
193  // nothing but `rm -r` of folders under the scratch roots is answered; everything else goes to the normal prompt.
194  on('classic.PermissionRequest', async ($, e, next) => {
195    if (e.tool_name !== 'Bash') return next(e)
196    const command = (e.tool_input as { command?: unknown } | null)?.command
197    if (typeof command !== 'string') return next(e)
198    try {
199      const [uid, home, cwd] = await Promise.all([
200        $.process.run(['id', '-u'], { timeoutMs: 3000 }).then(r => r.stdout.trim()),
201        $.env.get('HOME'),
202        $.session.cwd(),
203      ])
204      if (!uid || !home) return next(e)
205      const ctx = { uid, home, cwd }
206      const paths = scratchRemovals(command, ctx)
207      if (!paths) return next(e)
208      // Where each really is (links followed, `..` taken): a path that leaves the roots by either is not scratch.
209      // `<folder>/*` empties the folder: it is the folder that has to be under the roots (and the roots themselves are not).
210      const real = await $.process.run(['realpath', '-m', '--', ...paths.map(p => absolute(contentsOf(p) ?? p, cwd))], { timeoutMs: 3000 })
211      const resolved = real.stdout.split('\n').filter(Boolean)
212      if (real.exitCode !== 0 || resolved.length !== paths.length || !resolved.every(p => underScratch(p, ctx))) return next(e)
213      return { decision: { behavior: 'allow' } }
214    } catch {
215      return next(e)
216    }
217  })
218
219  // A compaction or /clear drops what the rules said: they're given again the next time a command touches them.
220  on('session.compact', async ($, e, next) => {
221    const result = await next(e)
222    const loop = e.agentId ?? ''
223    if (e.trigger !== 'precompute' && !('skip' in result && result.skip)) for (const key of [...attached]) if (key.startsWith(`${loop}|`)) attached.delete(key)
224    return result
225  })
226  on('session.end', async ($, e, next) => {
227    if (e.reason === 'clear') attached.clear()
228    return next(e)
229  })
230
231  on('turn.complete', async ($, e, next) => {
232    const result = await next(e)
233    // The sweep's answer: handed to the main thread as a prompt of its own, once the session is idle.
234    if (sweepId !== null && e.agentId === sweepId) {
235      sweepId = null
236      void $.prompt.submit({ text: reportOf(e.answer ?? '') }).catch(() => {})
237      return result
238    }
239    if (e.agentId !== undefined) return result
240    if (wroteThisTurn) counts.turns++
241    wroteThisTurn = false
242    if (sweepId !== null && (await $.clock.now()) - sweepAt > SWEEP_STALE_MS) sweepId = null
243    if (!dueOf(counts) || sweepId !== null || starting) return result
244    // Off the turn's dispatch: reading the brief and starting the sweep never hold the answer up.
245    starting = true
246    $.clock.after(0, () => void startSweep($))
247    return result
248  })
249}
250
hooks/rules.ts 111 lines
1/**
2 * The path-scoped rules (`.claude/rules/*.md`, PIE-841) for what a Bash command touches. Claude Code loads a rule when
3 * an agent reads a file its `paths:` match, never when it runs a command, so an agent that runs a test door from Bash
4 * without opening a door file would miss door.md. Each rule's own `paths:` decide, so the rules stay the one place
5 * that says where they apply. Pure: register.ts reads the files and attaches what this picks.
6 */
7
8import { simpleCommands } from './shell'
9
10export type Rule = { file: string; globs: string[]; body: string }
11
12/** A rule file: its `paths:` globs (frontmatter) and the text after the frontmatter. No `paths:`: null (it loads always). */
13export function ruleOf(file: string, text: string): Rule | null {
14  const m = /^---\n([\s\S]*?)\n---\n?/.exec(text.replace(/\r\n/g, '\n'))
15  if (!m) return null
16  // The list under `paths:` (its `- "glob"` lines, quoted either way or not), up to the next key.
17  const block = /^paths:\s*\n((?:[ \t]*-.*\n?)*)/m.exec(`${m[1]!}\n`)
18  const globs = block ? [...block[1]!.matchAll(/^\s*-\s*(["']?)(.+?)\1\s*$/gm)].map(g => g[2]!) : []
19  return globs.length ? { file, globs, body: text.replace(/\r\n/g, '\n').slice(m[0].length).trim() } : null
20}
21
22/** A `paths:` glob as a pattern: `**` any depth, `*` within a folder, `{a,b}` either; `dir/**` also matches `dir`. */
23function globPattern(glob: string): RegExp {
24  let out = ''
25  let depth = 0
26  for (let i = 0; i < glob.length; i++) {
27    const c = glob[i]!
28    if (glob.startsWith('/**', i) && i + 3 === glob.length) { out += '(/.*)?'; i += 2 }
29    else if (glob.startsWith('**/', i)) { out += '(.*/)?'; i += 2 }
30    else if (glob.startsWith('**', i)) { out += '.*'; i++ }
31    else if (c === '*') out += '[^/]*'
32    else if (c === '{') { out += '('; depth++ }
33    else if (c === '}' && depth) { out += ')'; depth-- }
34    else if (c === ',' && depth) out += '|'
35    else out += c.replace(/[.+?^$()|[\]\\]/g, '\\$&')
36  }
37  return new RegExp(`^${out}$`)
38}
39
40/** `a/b/../c` → `a/c`; `./x` → `x`. */
41function normal(path: string): string {
42  const parts: string[] = []
43  for (const p of path.split('/')) {
44    if (p === '' || p === '.') continue
45    if (p === '..') parts.pop()
46    else parts.push(p)
47  }
48  return parts.join('/')
49}
50
51/**
52 * The repository paths a command names, relative to the repository: the folder it runs in, and each word that looks
53 * like a path (relative to that folder, or absolute). An absolute path outside this checkout (another checkout of
54 * ep0ch, `~/projects/ep0ch/scripts/agent-env`) is read from each of its folders down, so it is matched too.
55 */
56export function pathsIn(command: string, cwd: string, root: string): string[] {
57  const rel = (abs: string) => abs === root ? '' : abs.startsWith(`${root}/`) ? abs.slice(root.length + 1) : null
58  const start = rel(cwd)
59  const paths = new Set<string>()
60  const at = (folder: string | null) => { if (folder) paths.add(normal(folder)) }
61  at(start)
62  // Read twice: with the wrappers' own words (`scripts/agent-env … --` is a path), and with what they run
63  // (`bash -c '…'`). Each reading starts from the shell's folder, so a `cd` is followed once per reading.
64  for (const reading of [simpleCommands(command, { unwrap: false }), simpleCommands(command)]) {
65    let here = start
66    for (const words of reading) {
67      // `cd <dir> && …`: what follows runs there.
68      if (words[0] === 'cd' && words[1] && !words[1].startsWith('-')) {
69        const to = words[1].replace(/^~(?=\/)/, '/~')
70        here = to.startsWith('/') ? rel(normal(to) ? `/${normal(to)}` : '/') : normal(`${here ?? ''}/${to}`)
71        at(here)
72        continue
73      }
74      for (const word of words) {
75        // A path, or a file name in the folder the command runs in (`cat home.ts` in packages/door/src).
76        const isFile = /^[\w.-]+\.[A-Za-z0-9]+$/.test(word)
77        if (!word.includes('/') && !isFile) continue
78        if (/^[a-z]+:\/\//.test(word)) continue
79        const w = word.replace(/^~(?=\/)/, '/~')
80        if (w.startsWith('/')) {
81          const abs = `/${normal(w)}`
82          const inside = rel(abs)
83          if (inside !== null) { at(inside); continue }
84          // Each tail of an absolute path elsewhere: /x/ep0ch/packages/door/… is tried as packages/door/… too.
85          const parts = abs.split('/').filter(Boolean)
86          for (let i = 1; i < parts.length; i++) paths.add(parts.slice(i).join('/'))
87        } else if (here !== null) {
88          at(`${here}/${w}`)
89        } else if (!isFile) {
90          // The shell is outside this checkout: a relative path is read from its root.
91          at(w)
92        }
93      }
94    }
95  }
96  return [...paths].filter(Boolean)
97}
98
99/** The rules whose `paths:` match one of `paths`, in the order given. */
100export function rulesFor(paths: readonly string[], rules: readonly Rule[]): Rule[] {
101  return rules.filter(rule => rule.globs.some(glob => {
102    const pattern = globPattern(glob)
103    return paths.some(p => pattern.test(p))
104  }))
105}
106
107/** What the agent reads after the command's result: the rule, and why it is here. */
108export function ruleContext(rule: Rule): string {
109  return `${rule.file} applies to what this command touches (path-scoped rules load when a file is read, not when a command runs; the ep0ch-repo mod added it):\n\n${rule.body}`
110}
111
hooks/guards.ts 131 lines
1/**
2 * The repository's hard rules for a Bash call, each a refusal with the reason and the command to run instead
3 * (AGENTS.md: "Never kill what you didn't start", "Merge with gh pr merge <n> --merge", "Schema and protocol";
4 * the builder's "Never git stash"). Pure: register.ts asks this, and the PROTOCOL lookup, per call.
5 */
6
7import { type GuardContext, SHARED_MACHINE, bunTest, wholeSuiteRefusal } from './whole-suite'
8import { baseName, simpleCommands } from './shell'
9
10export { wholeSuiteRefusal }
11
12
13export type { GuardContext }
14
15const MERGE = 'gh pr merge <n> --merge'
16
17/** `gh pr merge …`'s words after `merge`, or null when this isn't one. */
18export function prMergeArgs(words: readonly string[]): string[] | null {
19  return baseName(words[0] ?? '') === 'gh' && words[1] === 'pr' && words[2] === 'merge' ? words.slice(3) : null
20}
21
22/** gh pr merge's options that take a value as the next word. */
23const MERGE_VALUED = new Set(['-t', '--subject', '-b', '--body', '-F', '--body-file', '-A', '--author-email', '--match-head-commit', '-R', '--repo'])
24
25/** gh pr merge's flags, without the values the valued ones take (a body of `-d` is not a flag). */
26function flagsOf(args: readonly string[]): string[] {
27  const flags: string[] = []
28  for (let i = 0; i < args.length; i++) {
29    const w = args[i]!
30    if (MERGE_VALUED.has(w)) { i++; continue }
31    if (w.startsWith('-')) flags.push(w)
32  }
33  return flags
34}
35
36/** Short-flag clusters (`-sd`) hold each letter; a long flag is itself. */
37function hasFlag(flags: readonly string[], short: string, long: string): boolean {
38  return flags.some(w => w === long || w.startsWith(`${long}=`) || (/^-[A-Za-z]+$/.test(w) && w.slice(1).includes(short)))
39}
40
41/** The PR a `gh pr merge` names (a number, URL or branch), or null: the current branch's. */
42export function prNamed(args: readonly string[]): string | null {
43  for (let i = 0; i < args.length; i++) {
44    const w = args[i]!
45    if (MERGE_VALUED.has(w)) { i++; continue }
46    if (w.startsWith('-')) continue
47    return w
48  }
49  return null
50}
51
52/** The `--repo` a `gh pr merge` names, or null. */
53export function repoNamed(args: readonly string[]): string | null {
54  for (let i = 0; i < args.length; i++) {
55    const w = args[i]!
56    if (w === '-R' || w === '--repo') return args[i + 1] ?? null
57    if (w.startsWith('--repo=')) return w.slice('--repo='.length)
58  }
59  return null
60}
61
62/** Why one simple command is refused here, or null. */
63function refusalOf(words: readonly string[], ctx: GuardContext): string | null {
64  const name = baseName(words[0] ?? '')
65  if (name === 'pkill' || name === 'killall') {
66    return `${name} is refused in this repository: a pattern matches other agents' runs (one \`pkill -f "box-test door"\` killed several agents' suites). ` +
67      'Stop only what you started, by its pid: check `pstree -p <pid>`, then `kill <pid>`; for a test door or run under agent-env, `scripts/agent-env <name> --clean`.'
68  }
69  if (name === 'git') {
70    let i = 1
71    while (i < words.length && words[i]!.startsWith('-')) i += ['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(words[i]!) ? 2 : 1
72    if (words[i] === 'stash' && !['list', 'show'].includes(words[i + 1] ?? '')) {
73      return 'git stash is refused in this repository: stashes are shared by every worktree of the repo, so one agent pops another\'s. ' +
74        'Commit to your branch instead: `git add -A && git commit -m "wip: <what>"` (a merge commit keeps it in the branch history, which is fine).'
75    }
76    return null
77  }
78  const merge = prMergeArgs(words)
79  if (merge && hasFlag(flagsOf(merge), 'd', '--delete-branch')) {
80    const pr = prNamed(merge)
81    return 'gh pr merge with -d/--delete-branch is refused: it also deletes the local branch and its worktree, which can be another agent\'s, ' +
82      `and GitHub already deletes the remote branch. Run \`${pr ? MERGE.replace('<n>', pr) : MERGE}\`.`
83  }
84  if (name === 'bun' && ctx.hostname === SHARED_MACHINE) {
85    const test = bunTest(words)
86    if (test && test.args.some(w => w === '--parallel' || w.startsWith('--parallel='))) {
87      return `bun test --parallel is refused on ${SHARED_MACHINE}: several agents testing at once froze it (4 cores). ` +
88        'Run a focused test as `scripts/agent-env <your name> --test -- timeout 900 bun test <files>`, and a whole suite on float-box: `scripts/box-test <package>` (`scripts/box-test all --each` for a series).'
89    }
90  }
91  return null
92}
93
94/** Why `command` is refused here (the first rule it breaks), or null. */
95export function guardOf(command: string, ctx: GuardContext): string | null {
96  for (const words of simpleCommands(command)) {
97    const why = refusalOf(words, ctx)
98    if (why) return why
99  }
100  return wholeSuiteRefusal(command, ctx)
101}
102
103export type Merge = { pr: string | null; repo: string | null }
104
105/** Each `gh pr merge` in `command`: its PR (null: the current branch's) and `--repo`. */
106export function mergesIn(command: string): Merge[] {
107  return simpleCommands(command).flatMap(words => {
108    const merge = prMergeArgs(words)
109    return merge ? [{ pr: prNamed(merge), repo: repoNamed(merge) }] : []
110  })
111}
112
113/** `export const PROTOCOL = N` in outline-core's protocol.ts, or null. */
114export function protocolOf(source: string): number | null {
115  const m = /^export const PROTOCOL\s*=\s*(\d+)\s*;/m.exec(source)
116  return m ? Number(m[1]) : null
117}
118
119/**
120 * A PR that bumps PROTOCOL merges only at main's plus one (AGENTS.md "Rebase on the way in"): another merge may have
121 * taken the number since the branch picked it. `base` is PROTOCOL where the branch left main: a branch that never
122 * changed it (including a GRAMMAR-only change) is not a bump. Null when the branch leaves PROTOCOL alone, or bumps it right.
123 */
124export function protocolRefusal(main: number, branch: number, base: number, pr: string | null): string | null {
125  if (branch === base || branch === main + 1) return null
126  const merge = pr ? MERGE.replace('<n>', pr) : MERGE
127  return `This PR sets PROTOCOL to ${branch}, and main is at ${main}: a bump merges at main's plus one, so use ${main + 1}. ` +
128    `Rebase on origin/main, set \`export const PROTOCOL = ${main + 1};\` in packages/outline-core/src/protocol.ts, push, then \`${merge}\`. ` +
129    'When several bumps are queued, ask the deployer (loki) for the number first.'
130}
131
hooks/scratch.ts 73 lines
1/**
2 * Which `rm -r` commands are scratch cleanup. Claude Code stops on a "dangerous rm" (the working folder, a `..`, a
3 * glob that reaches it) even under bypass permissions, and a builder's prompt waits in the deployer's session; the
4 * mod answers "allow" for a command that is nothing but `rm -r` of folders under the scratch roots, and leaves
5 * every other command to the normal prompt. Pure: register.ts resolves the paths (`realpath -m`) and asks this.
6 */
7
8import { simpleCommands } from './shell'
9
10export type ScratchContext = { uid: string; home: string; cwd: string }
11
12/** The scratch roots, `scripts/scratch-clean`'s (a worktree under ~/wt goes by `git worktree remove`, not here). */
13export function scratchRoots(ctx: ScratchContext): string[] {
14  return [`/tmp/claude-${ctx.uid}`, `/tmp/ep0ch-agent-${ctx.uid}`, `${ctx.home}/.agent-env`]
15}
16
17/** Whether the resolved `path` is inside one of the roots (not a root itself). */
18export function underScratch(path: string, ctx: ScratchContext): boolean {
19  return scratchRoots(ctx).some(root => path.startsWith(`${root}/`) && path.length > root.length + 1)
20}
21
22/** The `rm` it runs: this one, never another executable of that name. */
23const RM = ['rm', '/bin/rm', '/usr/bin/rm']
24
25/** rm's options that stay within "remove these folders": recursive, force, verbose, and the end of options. */
26const OPTIONS = /^-[rRfv]+$|^--(recursive|force|verbose)$/
27
28/**
29 * The paths `command` removes when it is only `rm -r` commands over plain words (a variable, glob, brace, command
30 * substitution or redirection is a path nobody can read from here); null for anything else, or no path at all.
31 * `~` and `~/` are the home folder; the rest is as written, for the caller to resolve against the working folder.
32 */
33export function scratchRemovals(command: string, ctx: ScratchContext): string[] | null {
34  // The shell expands an unquoted ~ only, and the words below come back without their quotes.
35  if (/["'\\]~/.test(command)) return null
36  const targets: string[] = []
37  const commands = simpleCommands(command, { unwrap: false })
38  if (!commands.length) return null
39  for (const words of commands) {
40    if (!RM.includes(words[0] ?? '')) return null
41    let recursive = false
42    let done = false
43    for (const w of words.slice(1)) {
44      if (!done && w === '--') { done = true; continue }
45      if (!done && w.startsWith('-')) {
46        if (!OPTIONS.test(w)) return null
47        if (/^-[^-]*[rR]|^--recursive$/.test(w)) recursive = true
48        continue
49      }
50      if (w.startsWith('~') && w !== '~' && !w.startsWith('~/')) return null
51      if (/[$?{}`<>\\]/.test(w) || (/[*[\]!]/.test(w) && contentsOf(w) === null)) return null
52      targets.push(w === '~' ? ctx.home : w.startsWith('~/') ? `${ctx.home}/${w.slice(2)}` : w)
53    }
54    if (!recursive) return null
55  }
56  return targets.length ? targets : null
57}
58
59/** The glob endings that mean "everything in the folder": `*` and `.[!.]*` (the hidden entries, not `..`). */
60const CONTENTS = ['*', '.[!.]*']
61
62/** The folder a `<folder>/*` (or `.[!.]*`) word empties, or null when the word isn't one (its folder has no glob in it). */
63export function contentsOf(word: string): string | null {
64  const at = word.lastIndexOf('/')
65  const [folder, last] = at < 0 ? ['.', word] : [at === 0 ? '/' : word.slice(0, at), word.slice(at + 1)]
66  return CONTENTS.includes(last) && !/[*?[\]!]/.test(folder) ? folder : null
67}
68
69/** A command word read as written (`$` and the like make `scratchRemovals` give up before this). */
70export function absolute(path: string, cwd: string): string {
71  return path.startsWith('/') ? path : `${cwd}/${path}`
72}
73
hooks/freshness.ts 150 lines
1/**
2 * The freshness sweep: after a few deploys or merges (or turns that wrote to the outline), a background agent checks
3 * what `.claude/freshness.md` lists as stale here and reports to the main thread, which makes the updates. The agent
4 * only reads: the engine gives it read tools alone, and its Bash commands pass the read-only rule here. Pure: what
5 * counts, when to sweep, the brief and the read-only rule.
6 */
7
8import { mergesIn } from './guards'
9import { baseName, simpleCommands } from './shell'
10
11/** Deploys and merges between sweeps. */
12export const SWEEP_AFTER_CHANGES = 3
13/** Turns that wrote to the outline between sweeps (when fewer deploys and merges came). */
14export const SWEEP_AFTER_TURNS = 10
15
16/** Who sweeps: the deployer's sessions (the `.ep0ch` agent, or EP0CH_AGENT), unless EP0CH_FRESHNESS says otherwise. */
17export const SWEEPER = 'loki'
18
19/** `ep0ch install --apply` in a Bash command: a deploy. */
20export function isDeploy(command: string): boolean {
21  return simpleCommands(command).some(w => baseName(w[0] ?? '') === 'ep0ch' && w[1] === 'install' && w.includes('--apply'))
22}
23
24export function isMerge(command: string): boolean {
25  return mergesIn(command).length > 0
26}
27
28/** An outline write by any of the outline tools (the mod's, the MCP gateway's). */
29export function isOutlineWrite(tool: string): boolean {
30  return /^mcp__.+__(outline_(edit|patch|create|new|set_property|comment|reply|resolve_thread|assign_id|archive|write_status)|work_(create|set|stage|deliver|body|complete)|note_section)$/.test(tool)
31}
32
33export type Counts = { changes: number; turns: number }
34
35export function dueOf(c: Counts): boolean {
36  return c.changes >= SWEEP_AFTER_CHANGES || c.turns >= SWEEP_AFTER_TURNS
37}
38
39/**
40 * Whether this session sweeps: EP0CH_FRESHNESS `on` or `off` decides outright; else the session's agent name (the
41 * nearest `.ep0ch`'s `agent`, or EP0CH_AGENT) is the sweeper.
42 */
43export function sweepsFor(setting: string | undefined, agent: string | null): boolean {
44  const s = setting?.trim().toLowerCase()
45  if (s === 'on' || s === '1') return true
46  if (s === 'off' || s === '0') return false
47  return agent === SWEEPER
48}
49
50/** `agent = "<name>"` in a `.ep0ch`, or null. */
51export function agentOfEp0ch(text: string): string | null {
52  const m = /^\s*agent\s*=\s*"([^"]*)"\s*$/m.exec(text)
53  return m?.[1]?.trim() || null
54}
55
56/** The sweep's task: the stale list from `.claude/freshness.md`, why it runs now, and how to answer. */
57export function sweepBrief(freshness: string, why: string): string {
58  return [
59    'Freshness sweep. ' + why,
60    '',
61    'Check each item in the list below against what is true now (the outline through ep0ch show/find or the outline read tools, gh, git, systemctl --user). ' +
62      'Change nothing: you have read tools alone, and a Bash command that writes is refused. Read only what you need; keep it quick.',
63    '',
64    'Answer with findings only, one line each: what is stale, the evidence (a ((block id)), PIE-n, #PR or file path), and the exact fix the main thread should make. ' +
65      'Skip what is fine. If nothing is stale, answer "Nothing stale." Your answer is handed to the main thread, which makes the updates.',
66    '',
67    '--- .claude/freshness.md ---',
68    freshness.trim(),
69  ].join('\n')
70}
71
72/** What the main thread reads when the sweep answers. */
73export function reportOf(answer: string): string {
74  return `Freshness sweep (a read-only agent the ep0ch-repo mod started) found:\n\n${answer.trim() || 'Nothing stale.'}\n\n` +
75    'Make the updates that hold up, attributed as yourself; the sweep changed nothing.'
76}
77
78/**
79 * What the sweep agent may call: the engine refuses anything else. Bash is on the list, and each Bash command goes
80 * through scripts/read-only-bash.ts (a PreToolUse hook of the agent's own), which refuses one that writes.
81 */
82export const SWEEP_TOOLS: readonly string[] = [
83  'Read', 'Grep', 'Glob', 'Bash', 'ToolSearch',
84  ...['outline_read', 'outline_find', 'outline_changes'].map(t => `mcp__ep0ch__${t}`),
85]
86
87/** The sweep agent's system prompt; the spawn's prompt is the brief. */
88export const SWEEP_SYSTEM =
89  "You are ep0ch's freshness sweep, started by the ep0ch-repo mod in the deployer's session. You only read: the engine " +
90  'gives you read tools alone, and a Bash command that writes is refused. Check what the brief lists, quickly, and answer ' +
91  'with findings only. Your answer is handed to the main thread, which makes the updates.'
92
93/** The words after a program's name that aren't options. */
94const operands = (w: readonly string[]) => w.slice(1).filter(a => !a.startsWith('-'))
95const noneOf = (w: readonly string[], refused: RegExp) => !w.slice(1).some(a => refused.test(a))
96
97/**
98 * Bash programs the sweep may run, each with the check its arguments must pass. Deny by default: a program that can
99 * write a file or run another command (sed's `w` and `e`, sort's `-o`, rg's `--pre`, awk, xargs) isn't here.
100 */
101const READ_PROGRAMS: Record<string, (w: readonly string[]) => boolean> = {
102  cat: () => true, head: () => true,
103  // A follow never ends: the sweep would hang on it.
104  tail: w => noneOf(w, /^(-f|-F|--follow)/), wc: () => true, ls: () => true, grep: () => true,
105  stat: () => true, date: () => true, pwd: () => true, echo: () => true, cut: () => true, tr: () => true,
106  hostname: () => true, uptime: () => true, df: () => true, free: () => true, ps: () => true, pstree: () => true,
107  which: () => true, basename: () => true, dirname: () => true, realpath: () => true,
108  jq: w => noneOf(w, /^--(rawfile|slurpfile)$/),
109  rg: w => noneOf(w, /^--pre/),
110  sort: w => noneOf(w, /^(-o|--output)/),
111  find: w => noneOf(w, /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/),
112  git: w => {
113    const sub = w[1] ?? ''
114    if (!noneOf(w, /^--output/)) return false
115    if (['log', 'show', 'diff', 'status', 'rev-parse', 'ls-files', 'blame', 'merge-base', 'describe', 'shortlog', 'cat-file'].includes(sub)) return true
116    // git branch lists only: no branch name, no flag that changes one.
117    return sub === 'branch' && operands(w).length === 1 && w.slice(2).every(a => ['-a', '-r', '-v', '-vv', '--list', '--all', '--remotes', '--merged', '--no-merged', '--show-current'].includes(a))
118  },
119  gh: w => {
120    if ((w[1] === 'pr' && ['list', 'view', 'checks', 'diff', 'status'].includes(w[2] ?? '')) || (w[1] === 'run' && ['list', 'view'].includes(w[2] ?? ''))) return true
121    // gh api reads only with no fields (a field makes it a POST) and no method of its own.
122    return w[1] === 'api' && noneOf(w, /^(-[fFX]|--(field|raw-field|input|method))/)
123  },
124  ep0ch: w => ['show', 'find', 'where', 'status', 'help'].includes(w[1] ?? '') || (w[1] === 'outline' && w[2] === 'list') || (w[1] === 'echo' && w[2] === 'status'),
125  // systemctl --user <read verb> …: the verb is its first operand.
126  systemctl: w => w.includes('--user') && ['status', 'is-active', 'is-enabled', 'is-failed', 'list-units', 'list-timers', 'show', 'cat'].includes(operands(w)[0] ?? ''),
127  journalctl: w => noneOf(w, /^(-f|--follow|--(rotate|vacuum|flush|sync|relinquish|setup-keys|update-catalog))/),
128  tailscale: w => w[1] === 'status',
129  // ssh <machine> '<command>', no options (an option can run a command here): as read-only as the command it runs there.
130  ssh: w => w.length >= 3 && !w[1]!.startsWith('-') && bashReadRefusal(w.slice(2).join(' ')) === null,
131}
132
133/** Why the sweep may not run `command`, or null when it only reads. */
134export function bashReadRefusal(command: string): string | null {
135  // A substitution runs a command the checks below can't see whole; process substitution too.
136  if (/\$\(|`|<\(|>\(/.test(command)) return 'a command substitution runs a command of its own'
137  // A redirection writes a file; one to /dev/null, or of one stream to another, doesn't. A `>` in quotes is an
138  // argument (`--search "merged:>=…"`): no shell runs it, as no wrapper is allowed (ssh's command is checked on its own).
139  if (command.replace(/'[^']*'|"[^"]*"/g, '').replace(/\d*>>?\s*\/dev\/null|\d*>&\d/g, '').includes('>')) return 'a redirection writes a file'
140  // Wrappers are not looked through: `env`, `xargs`, `bash -c` aren't on the list.
141  for (const w of simpleCommands(command, { unwrap: false })) {
142    const name = baseName(w[0] ?? '')
143    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0] ?? '')) return 'a variable set before a command changes what it runs'
144    const check = Object.hasOwn(READ_PROGRAMS, name) ? READ_PROGRAMS[name] : undefined
145    if (!check) return `\`${name}\` isn't one of the commands the sweep may run`
146    if (!check(w)) return `\`${w.slice(0, 3).join(' ')}\` changes something`
147  }
148  return null
149}
150
hooks/shell.ts 159 lines
1/**
2 * Just enough of a shell command's shape for the guards and the sweep's read-only check: the simple commands in it,
3 * each as words, with what wraps them taken off (`VAR=x`, `timeout 900`, `scripts/agent-env loki --`, `bash -c '…'`)
4 * and a heredoc's body left out (a PR body that mentions `pkill` is not a pkill). Pure.
5 */
6
7/** The command lines of `command`: a backslash-newline joins two, and a heredoc's body is dropped. */
8function linesOf(command: string): string[] {
9  const out: string[] = []
10  let until: string | null = null
11  for (const line of command.replace(/\\\n/g, ' ').split('\n')) {
12    if (until !== null) {
13      if (line.trim() === until) until = null
14      continue
15    }
16    out.push(line)
17    until = heredocOf(line)
18  }
19  return out
20}
21
22/** The delimiter of a heredoc this line opens (`<<EOF`, `<<-'EOF'`), outside quotes and not `<<<`; else null. */
23function heredocOf(line: string): string | null {
24  let quote: string | null = null
25  for (let i = 0; i < line.length; i++) {
26    const c = line[i]!
27    if (quote) { if (c === quote) quote = null; else if (c === '\\' && quote === '"') i++; continue }
28    if (c === '"' || c === "'") { quote = c; continue }
29    if (c === '\\') { i++; continue }
30    if (c === '<' && line[i + 1] === '<' && line[i + 2] !== '<' && line[i - 1] !== '<') {
31      const m = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line.slice(i))
32      if (m) return m[2]!
33    }
34  }
35  return null
36}
37
38/** Where the `$(…)` that opens just before `from` closes (the index of its `)`), counting nested parentheses. */
39function closingParen(line: string, from: number): number {
40  let depth = 1
41  for (let i = from; i < line.length; i++) {
42    if (line[i] === '(') depth++
43    else if (line[i] === ')' && --depth === 0) return i
44  }
45  return line.length
46}
47
48export type ShellOptions = {
49  /** Look through wrappers (`timeout`, `env`, `sudo`, `xargs`, `bash -c '…'`) to the command they run. Default true. */
50  unwrap?: boolean
51  /** Stop at `scripts/agent-env <name> … --` and return the command with it, rather than looking through it. */
52  keepAgentEnv?: boolean
53}
54
55/**
56 * The words of each simple command: split at unquoted `;`, `&&`, `||`, `|`, `&` and newlines, quotes kept together
57 * (and taken off). A `$(…)` or backtick substitution, inside double quotes too, is read as commands of its own.
58 */
59export function simpleCommands(command: string, options: ShellOptions = {}): string[][] {
60  const commands: string[][] = []
61  for (const line of linesOf(command)) {
62    let words: string[] = []
63    let word = ''
64    let inWord = false
65    let quote: '"' | "'" | null = null
66    const endWord = () => { if (inWord) words.push(word); word = ''; inWord = false }
67    const endCommand = () => { endWord(); if (words.length) commands.push(words); words = [] }
68    for (let i = 0; i < line.length; i++) {
69      const c = line[i]!
70      if (quote === "'") {
71        if (c === quote) quote = null
72        else word += c
73        continue
74      }
75      if (quote === '"') {
76        if (c === '"') quote = null
77        else if (c === '\\' && i + 1 < line.length) word += line[++i]
78        else if (c === '$' && line[i + 1] === '(') {
79          const end = closingParen(line, i + 2)
80          commands.push(...simpleCommands(line.slice(i + 2, end), options))
81          i = end
82        } else if (c === '`') {
83          const end = line.indexOf('`', i + 1)
84          commands.push(...simpleCommands(line.slice(i + 1, end < 0 ? line.length : end), options))
85          i = end < 0 ? line.length : end
86        } else word += c
87        continue
88      }
89      if (c === '"' || c === "'") { quote = c; inWord = true; continue }
90      if (c === '\\' && i + 1 < line.length) { word += line[++i]; inWord = true; continue }
91      if (c === '#' && !inWord) break
92      // `2>&1`: the & is the redirection's, not a separator.
93      if (c === '&' && line[i - 1] === '>') { word += c; inWord = true; continue }
94      if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || c === '`' || c === '{' || c === '}') { endCommand(); continue }
95      if (c === '$' && line[i + 1] === '(') { endCommand(); i++; continue }
96      if (c === ' ' || c === '\t') { endWord(); continue }
97      word += c
98      inWord = true
99    }
100    endCommand()
101  }
102  return options.unwrap === false ? commands.map(dropKeywords).filter(w => w.length) : commands.flatMap(words => unwrap(words, options))
103}
104
105/** Shell keywords that come before a command (`if`, `then`, `do`, `!` …): not the command. */
106const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '!', 'case', 'esac'])
107
108function dropKeywords(words: string[]): string[] {
109  let i = 0
110  while (i < words.length && KEYWORDS.has(words[i]!)) i++
111  return words.slice(i)
112}
113
114/** Skips a wrapper's own options (`-u root`: `valued` names those that take the next word). */
115function afterOptions(words: readonly string[], valued: readonly string[]): number {
116  let i = 1
117  while (i < words.length && words[i]!.startsWith('-')) i += valued.includes(words[i]!) ? 2 : 1
118  return i
119}
120
121/** Words that run the rest as a command of their own: how many words of their own come first. */
122const WRAPPERS: Record<string, (words: string[]) => number> = {
123  sudo: w => afterOptions(w, ['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U']),
124  xargs: w => afterOptions(w, ['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a']),
125  nohup: () => 1, exec: () => 1, command: () => 1, time: () => 1,
126  nice: w => afterOptions(w, ['-n']),
127  env: words => { let i = 1; while (i < words.length && (words[i]!.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!))) i++; return i },
128  timeout: words => afterOptions(words, ['-s', '-k', '--signal', '--kill-after']) + 1,
129}
130
131/** One simple command with what wraps it taken off; `sh -c '…'` is read as the commands in its string. */
132function unwrap(words: string[], options: ShellOptions = {}): string[][] {
133  let rest = dropKeywords(words)
134  for (;;) {
135    while (rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[0]!)) rest = rest.slice(1)
136    if (!rest.length) return []
137    const name = baseName(rest[0]!)
138    const wrapper = Object.hasOwn(WRAPPERS, name) ? WRAPPERS[name] : undefined
139    if (wrapper) { rest = rest.slice(wrapper(rest)); continue }
140    // scripts/agent-env <name> [--test] [--parallel …] -- <command>
141    if (name === 'agent-env') {
142      const at = rest.indexOf('--')
143      if (at < 0 || options.keepAgentEnv) return [rest]
144      rest = rest.slice(at + 1)
145      continue
146    }
147    // sh -c '…', bash -lc '…'
148    if (['sh', 'bash', 'zsh', 'dash'].includes(name)) {
149      const at = rest.findIndex((w, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(w))
150      if (at > 0 && rest[at + 1] !== undefined) return simpleCommands(rest[at + 1]!, options)
151    }
152    return [rest]
153  }
154}
155
156export function baseName(word: string): string {
157  return word.slice(word.lastIndexOf('/') + 1)
158}
159
hooks/whole-suite.ts 84 lines
1/**
2 * Whether a shell command runs a whole test suite straight on the shared test machines (PIE-998), shared by the Claude mod
3 * (`hooks/guards.ts`) and the Codex hook (`.codex/hooks/box-test-only.ts`) so the two tools
4 * never disagree. Pure.
5 */
6import { baseName, simpleCommands } from './shell'
7
8export type GuardContext = { hostname: string }
9
10/** The machine where whole suites never run (AGENTS.md "Tests on a shared machine"). */
11export const SHARED_MACHINE = 'float-2'
12/** The test machine: whole suites run there through scripts/box-test, which caps and cleans them (PIE-998). */
13export const BOX_MACHINE = 'float-box'
14
15/** bun's own options that take the next word (`bun --cwd packages/door test`, `bun run --cwd … test`). */
16const BUN_VALUED = new Set(['--cwd', '--filter', '--config', '-c', '--env-file', '--elide-lines', '--shell', '--install'])
17/** `bun test`'s options that take the next word as a value (not a file). Flags like --bail take theirs with `=`. */
18const TEST_VALUED = new Set(['-t', '--test-name-pattern', '--timeout', '--rerun-each', '--preload', '--coverage-reporter', '--coverage-dir', '--reporter', '--reporter-outfile', '--max-concurrency', '--shard'])
19
20/** The index of the first word from `from` that is not an option (or the value of a valued one), or -1. */
21function firstWord(words: readonly string[], from: number, valued: ReadonlySet<string>): number {
22  for (let i = from; i < words.length; i++) {
23    const w = words[i]!
24    if (valued.has(w)) { i++; continue }
25    if (!w.startsWith('-')) return i
26  }
27  return -1
28}
29
30/**
31 * What a `bun test …` or `bun run test…` command is, or null for any other command: its arguments after the
32 * subcommand (a `run`'s script is the first of them) and the files it names. The one place that reads bun's command line.
33 */
34export function bunTest(words: readonly string[]): { args: string[]; files: string[]; script: boolean } | null {
35  if (baseName(words[0] ?? '') !== 'bun') return null
36  const sub = firstWord(words, 1, BUN_VALUED)
37  if (sub < 0) return null
38  if (words[sub] === 'run' || words[sub] === 'x') {
39    const at = firstWord(words, sub + 1, BUN_VALUED)
40    const script = at < 0 ? undefined : words[at]
41    return script === 'test' || script?.startsWith('test:') ? { args: words.slice(at + 1) as string[], files: [], script: true } : null
42  }
43  if (words[sub] !== 'test') return null
44  const args = words.slice(sub + 1) as string[]
45  const files: string[] = []
46  for (let i = 0; i < args.length; i++) {
47    const w = args[i]!
48    if (TEST_VALUED.has(w)) { i++; continue }
49    if (w.startsWith('-')) continue
50    // A named file (or a name that selects one) is a focused run; a folder is not.
51    if (/\.(?:[cm]?[jt]sx?)$/.test(w) || /\.test\b|\.spec\b/.test(w)) files.push(w)
52  }
53  return { args, files, script: false }
54}
55
56/**
57 * Why a whole-suite run straight on float-box or float-2 is refused, or null. A run under `scripts/agent-env` (the
58 * focused, capped way) is not a `bun` command to this rule, so it passes, and `scripts/box-test` is not bun at all.
59 */
60export function wholeSuiteRefusal(command: string, ctx: GuardContext): string | null {
61  if (ctx.hostname !== SHARED_MACHINE && ctx.hostname !== BOX_MACHINE) return null
62  for (const words of simpleCommands(command, { keepAgentEnv: true })) {
63    const test = bunTest(words)
64    if (!test || test.files.length > 0) continue
65    return `A whole-suite \`${words.join(' ')}\` is refused on ${ctx.hostname}: suites run only through scripts/box-test, which limits how many run at once (EP0CH_BOX_SLOTS), allows one per worktree, and kills everything a run started (a hand-started suite froze float-box at load 22 and left hundreds of orphans: PIE-998). ` +
66      'Run `scripts/box-test <package>` (`scripts/box-test all --each` for a series) from your worktree; for a few test files, `scripts/agent-env <your name> --test -- timeout 900 bun test <files>`.'
67  }
68  return null
69}
70
71/**
72 * A Codex PreToolUse hook's answer to its input (the event JSON): the JSON to print to refuse the command, or null to
73 * let it run. Anything unreadable, or not a Bash command, runs.
74 */
75export function codexPreToolUse(input: string, ctx: GuardContext): string | null {
76  try {
77    const command = (JSON.parse(input) as { tool_input?: { command?: unknown } }).tool_input?.command
78    const why = typeof command === 'string' ? wholeSuiteRefusal(command, ctx) : null
79    return why ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: why } }) : null
80  } catch {
81    return null
82  }
83}
84