SLOPSHOPPER

harness-rules

Enforces the global CLAUDE.md rules on agent models, the 5-hour quota, cleanup commands and posted bodies.

newguardprocessagents
A shopper browsing a rack in a slop shop
README

dotfiles

My AI-agent setup and Linux workstation, as code.

CI License

AI harness config · Global agent rules · Apps · Shell · Sync · Host limits · Development

A terminal runs "deno task ai --check". It lists the three harness homes (Claude Code, OpenCode, DSH) and prints "All targets in sync."

I run several AI coding agents at once, in Claude Code, OpenCode and DSH (a DeepSeek harness). Their rules, skills, agents and settings are written once, in ai-harnesses/, and deno task ai renders them into the format each harness reads. deno task ai --check, shown above, writes nothing: it compares what every harness on this machine reads with what is committed, and exits 1 if they differ.

The rest of the repository is the workstation those agents run on: the apps, the shell, the config files synced into my home directory, and the kernel and systemd limits a dozen parallel agent sessions need. It is this machine's live config: a merged change is not done until it is applied here.

The AI-agent setup

  • One rules file, every harness. ai-harnesses/AGENTS.md holds the global rules: autonomy, Git flow, secrets, cleanup, subagent orchestration. It becomes Claude Code's CLAUDE.md and OpenCode's and DSH's AGENTS.md, byte for byte.
  • Agents and skills, written once. The agents and skills in one harness-neutral format. Validated frontmatter picks a model tier and effort per agent; each adapter renders its harness's format, covered by golden tests.
  • A reviewer gate. A separate, read-only reviewer runs the checks itself, breaks the code to prove each test goes red, and counts every claim in the PR body. Its verdict is what authorises a merge.
  • Nothing left running. tools/sweep-orphans.sh lists the processes and stale systemd-run scopes an agent session left behind, and stops exactly those on request. The rules cap the processes and memory of anything that spawns processes, and give every wait a deadline.
  • Costs you can see. tools/session-cost.ts reports each session's and subagent's cost, peak context and compactions from Claude Code's transcripts.
  • Secrets stay local. Committed env files are age-encrypted one value per line, and tools/env-key-copy.ts gives a worktree its key without the key ever being printed.

How the rendering works, the file layout per harness and how to write a skill or an agent: ai-harnesses/README.md.

The workstation

  • Apps. install-apps.ts installs everything in apps.jsonc with dnf, apt or zypper, and falls back to Flatpak.
  • Shell. install-shell.ts sets up Zsh, Oh My Zsh, Powerlevel10k and the aliases in aliases.sh.
  • Config in home. tmux, Neovim, Zsh and the prompt are symlinked from this repository.
  • Limits for many agents. system/ raises inotify instances and zram swap, cleans /tmp sooner, and caps the tasks each app can start.
  • Synced, not cloned. The repository lives in a Syncthing folder, and agent worktrees are ignored so they never replicate.

Use it if you want a working example of one rules source driving several AI coding agents, or a Linux workstation set up by script. Skip it if you want a framework: these are my own settings, not a configurable product.

Quick start

curl -fsSL https://deno.land/install.sh | sh   # install Deno
git clone https://github.com/spy4x/dotfiles && cd dotfiles
deno task install-all      # apps, then the shell
deno task ai --check       # compare your harness homes with ai-harnesses/, write nothing

deno task ai without --check replaces the global rules, skills and agents of every installed harness with mine, and merges my settings into theirs. Read ai-harnesses/README.md first.

Development

deno task test             # adapter golden files, engine, tools
deno task ai --check       # exit 1 if any harness differs from ai-harnesses/

File layout, adding apps and the encrypted env file: development.md.

Licence

MIT.

Built by

I'm Anton Shubin, a senior full-stack engineer and tech lead. This is how I run AI agents on real work, on my own machine. Need something like it built for your product? That's my day job →


Made by Anton Shubin · antonshubin.com/tools

Source 2 files
hooks/register.ts 124 lines
1import type { EngineInterface, Register } from "claude-code"
2import {
3  ALLOW,
4  bashVerdict,
5  ghPost,
6  leakVerdict,
7  markerVerdict,
8  spawnVerdict,
9  type Verdict,
10  type Where,
11} from "./rules.ts"
12
13/**
14 * Added to every refused Bash call. Agents often fix a body (`sed -i '1i <!-- agent -->'`) and
15 * post it in one command, then retry that same command after the refusal.
16 */
17const NOTHING_RAN = `Nothing in this command ran, not even the steps before the refused one: ` +
18  `run any fix in a command of its own, then retry.`
19
20const STDIN_BODY = `Write the body to a file and pass its absolute path to --body-file: ` +
21  `this mod cannot read a body from standard input.`
22
23/**
24 * Appends one JSON line to ~/.claude/mods-log/harness-rules.jsonl: every spawn and every
25 * refusal, so the experiment can count what the mod did. Fail-open: a lost line is no reason
26 * to stop work.
27 */
28async function log($: EngineInterface, entry: Record<string, unknown>): Promise<void> {
29  try {
30    const home = await $.env.get(`HOME`)
31    if (!home) return
32    const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), ...entry })
33    await $.process.run(
34      [
35        `sh`,
36        `-c`,
37        `mkdir -p "$1" && cat >> "$1/harness-rules.jsonl"`,
38        `sh`,
39        `${home}/.claude/mods-log`,
40      ],
41      { stdin: `${line}\n`, timeoutMs: 2000 },
42    )
43  } catch {
44    // Logging is not critical.
45  }
46}
47
48/** The 5-hour window's use in percent, when the session has a reading. */
49async function fiveHourPercent($: EngineInterface): Promise<number | undefined> {
50  const { rateLimits } = await $.session.usage()
51  return rateLimits.find((limit) => limit.kind === `five_hour`)?.percentUsed
52}
53
54/**
55 * The session's directory and home, for the `rm` rule. Each is left out when it cannot be read:
56 * the rule then still refuses variable paths and the paths it can judge without them.
57 */
58async function whereOf($: EngineInterface): Promise<Where> {
59  const [cwd, home] = await Promise.all([
60    $.session.cwd().catch(() => undefined),
61    $.env.get(`HOME`).catch(() => undefined),
62  ])
63  return { cwd, home }
64}
65
66export const register: Register = (on) => {
67  on(`agent.spawn`, async ($, e, next) => {
68    const verdict = spawnVerdict(e, await fiveHourPercent($))
69    await log($, {
70      event: `spawn`,
71      subagentType: e.subagentType,
72      model: e.model ?? null,
73      fork: e.fork,
74      parentAgentId: e.parentAgentId ?? null,
75      deny: verdict.deny ?? null,
76    })
77    return verdict.deny ? { deny: verdict.deny } : next(e)
78  })
79
80  on(`tool.call`, { tool: `Bash` }, async ($, e, next) => {
81    const verdict = bashVerdict(e.command, await whereOf($))
82    if (!verdict.deny) return next(e)
83    // The whole command, read on this machine to judge each refusal; none when it also posts,
84    // since a post's body may hold a secret.
85    const command = ghPost(e.command).kind === `none` ? e.command : null
86    await log($, { event: `bash`, command, deny: verdict.deny })
87    return { deny: `${verdict.deny} ${NOTHING_RAN}` }
88  })
89
90  // A body we post leaves the machine, so this guard fails closed: a hook that throws refuses,
91  // and a re-entry (a gh call another hook makes beneath this one) is refused when it posts.
92  on(`tool.call`, { tool: `Bash` }, async ($, e, next) => {
93    const post = ghPost(e.command)
94    if (post.kind === `none`) return next(e)
95    let verdict: Verdict = post.kind === `stdin` ? { deny: STDIN_BODY } : ALLOW
96    const body = post.kind === `file`
97      ? await $.fs.read(post.path)
98      : post.kind === `inline`
99      ? post.body
100      : ``
101    if (!verdict.deny) verdict = markerVerdict(body)
102    if (!verdict.deny) {
103      const scan = await $.process.run(
104        [`gitleaks`, `stdin`, `--no-banner`, `--redact`],
105        // The whole command too: the word parser is not a shell, and a body it cut short
106        // (an escaped quote, `$(…)`) must not hide a secret.
107        { stdin: post.kind === `file` ? `${body}\n${e.command}` : e.command, timeoutMs: 8000 },
108      )
109      verdict = leakVerdict(scan.exitCode)
110    }
111    if (!verdict.deny) return next(e)
112    // The command can hold the secret gitleaks found, so the log keeps only the refusal.
113    await log($, { event: `gh`, deny: verdict.deny })
114    return { deny: `${verdict.deny} ${NOTHING_RAN}` }
115  }).catch((_$, e, next) => {
116    const refusal = {
117      deny: `The check of this post failed: its body file could not be read, or gitleaks did ` +
118        `not run. It was not sent. ${NOTHING_RAN}`,
119    }
120    if (next.error.kind === `re-entry`) return ghPost(e.command).kind === `none` ? next(e) : refusal
121    return next.called ? next(e) : refusal
122  })
123}
124
hooks/rules.ts 274 lines
1// The rules this mod enforces, as pure functions: no `$`, so `claude plugin test` checks them
2// directly. register.ts wires them to events.
3
4/** What a rule decides: nothing to say, or a refusal with the reason the model reads. */
5export interface Verdict {
6  readonly deny?: string
7}
8
9export const ALLOW: Verdict = {}
10
11/** The marker every comment, issue and PR body we post starts with (global CLAUDE.md). */
12export const AGENT_MARKER = `<!-- agent -->`
13
14/** At or above this share of the 5-hour limit, no new agent starts (wave skill, "Usage"). */
15export const FIVE_HOUR_STOP = 90
16
17/** The fields of an `agent.spawn` the rules read. */
18export interface Spawn {
19  readonly subagentType: string
20  readonly model?: string
21  readonly fork: boolean
22}
23
24/**
25 * Judges a subagent spawn. A fork inherits its parent's model by design, so only the quota
26 * applies to it. Everything else must name its model, and a reviewer must run on Opus.
27 */
28export function spawnVerdict(spawn: Spawn, fiveHourPercent: number | undefined): Verdict {
29  if (fiveHourPercent !== undefined && fiveHourPercent >= FIVE_HOUR_STOP) {
30    return {
31      deny: `The 5-hour limit is at ${fiveHourPercent}%. Start no new agent: let the running ` +
32        `ones finish, post the handoff and end the turn.`,
33    }
34  }
35  if (spawn.fork) return ALLOW
36  if (spawn.model === undefined) {
37    return {
38      deny: `Pass \`model\` on every Agent call: an omitted one inherits the lead's model. ` +
39        `Search and inventory: haiku. Implementers: sonnet, or opus for UI libraries and ` +
40        `security work. Everything else, reviewers included: opus.`,
41    }
42  }
43  if (spawn.subagentType === `reviewer` && !spawn.model.includes(`opus`)) {
44    return { deny: `Reviewers run on opus (global CLAUDE.md, "Reviewers stay on opus").` }
45  }
46  return ALLOW
47}
48
49/**
50 * Splits a shell command into simple commands, each a list of words. Quotes group words and are
51 * dropped; `;`, `&`, `|` and newlines outside quotes end a command. No expansion: `"$D"` stays
52 * the word `$D`. Best effort, not a shell: a heredoc's lines read as commands of their own.
53 */
54export function commandsOf(command: string): string[][] {
55  const commands: string[][] = []
56  let words: string[] = []
57  let word: string | undefined
58  let quote: string | undefined
59  const endWord = () => {
60    if (word !== undefined) words.push(word)
61    word = undefined
62  }
63  const endCommand = () => {
64    endWord()
65    if (words.length > 0) commands.push(words)
66    words = []
67  }
68  for (const char of command) {
69    if (quote) {
70      if (char === quote) quote = undefined
71      else word = (word ?? ``) + char
72    } else if (char === `"` || char === `'`) {
73      quote = char
74      word = word ?? ``
75    } else if (/[;&|\n]/.test(char)) endCommand()
76    else if (/\s/.test(char)) endWord()
77    else word = (word ?? ``) + char
78  }
79  endCommand()
80  return commands
81}
82
83/** Words that run the command after them: `sudo rm`, `then rm`, `timeout 60 rm`. */
84const PREFIXES = [
85  `sudo`,
86  `command`,
87  `builtin`,
88  `exec`,
89  `nohup`,
90  `time`,
91  `env`,
92  `timeout`,
93  `if`,
94  `then`,
95  `else`,
96  `elif`,
97  `while`,
98  `until`,
99  `do`,
100  `!`,
101]
102
103/** Prefix flags that take a value: `sudo -u x`, `timeout -s KILL`. */
104const VALUE_FLAGS = [`-u`, `-g`, `-s`, `-k`]
105
106/**
107 * Drops what runs before the command proper: prefixes and their flags, a `timeout` duration,
108 * `X=1` assignments, and a `(` or `{` that opens a group. So `do (X=1 sudo -n rm` reads as `rm`.
109 */
110function commandProper(words: string[]): string[] {
111  const rest = [...words]
112  let i = 0
113  while (i < rest.length) {
114    const word = (rest[i] ?? ``).replace(/^[({]+/, ``)
115    rest[i] = word
116    if (word === `` || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
117      i++
118    } else if (PREFIXES.includes(word)) {
119      i++
120      while (rest[i]?.startsWith(`-`)) i += VALUE_FLAGS.includes(rest[i] ?? ``) ? 2 : 1
121      if (word === `timeout`) i++
122    } else break
123  }
124  return rest.slice(i).map((word) => word.replace(/[)}]+$/, ``))
125}
126
127/** Where a command runs: the session's directory and the home directory, both absolute. */
128export interface Where {
129  readonly cwd?: string
130  readonly home?: string
131}
132
133const LITERAL_PATH = `Delete by literal path: \`find "<literal path>" -delete\`, or \`rm -rf\` ` +
134  `a literal path.`
135
136/**
137 * Resolves `path` the way the shell would name it, without expansion: `~` becomes `home`, a
138 * relative path joins `cwd`, and `.`, `..`, a trailing `/` or `/*` fold away. Undefined when
139 * the path is relative or starts with `~` and that base is unknown.
140 */
141function resolved(path: string, { cwd, home }: Where): string | undefined {
142  let full = path
143  if (full === `~` || full.startsWith(`~/`)) {
144    if (!home) return undefined
145    full = home + full.slice(1)
146  } else if (!full.startsWith(`/`)) {
147    if (!cwd) return undefined
148    full = `${cwd}/${full}`
149  }
150  const parts: string[] = []
151  for (const part of full.replace(/\/\*$/, ``).split(`/`)) {
152    if (part === `..`) parts.pop()
153    else if (part !== `` && part !== `.`) parts.push(part)
154  }
155  return `/${parts.join(`/`)}`
156}
157
158/**
159 * What `path` is when Claude Code guards it even in bypass mode: `/`, a top-level directory,
160 * home, or the working directory or one of its parents. Undefined for any other path.
161 */
162function criticalKind(path: string, where: Where): string | undefined {
163  if ([`~`, `~/`].includes(path)) return `home`
164  if ([`.`, `./`, `./*`, `*`].includes(path)) return `the working directory`
165  if ([`..`, `../`].includes(path)) return `a parent of the working directory`
166  const full = resolved(path, where)
167  if (full === undefined) return undefined
168  if (full === `/`) return `the root`
169  if (full.split(`/`).length === 2) return `a top-level directory`
170  if (where.home && full === resolved(where.home, {})) return `home`
171  const cwd = where.cwd ? resolved(where.cwd, {}) : undefined
172  if (cwd === full) return `the working directory`
173  if (cwd?.startsWith(`${full}/`)) return `a parent of the working directory`
174  return undefined
175}
176
177/**
178 * Judges a Bash command against the cleanup rules in the global CLAUDE.md ("Leave nothing
179 * running"): `rm` and `rmdir` take only literal paths, never a critical one, and no `find /`.
180 * Claude Code stops for approval on critical paths and some substitutions even in bypass mode;
181 * a refusal instead tells the agent how to retry. Best effort: it reads the command's words, not
182 * what the shell would expand.
183 */
184export function bashVerdict(command: string, where: Where = {}): Verdict {
185  for (const words of commandsOf(command).map(commandProper)) {
186    const name = (words[0] ?? ``).replace(/^\\/, ``).split(`/`).pop()
187    if (name === `rm` || name === `rmdir`) {
188      const paths = words.slice(1).filter((word) => !word.startsWith(`-`))
189      if (paths.some((path) => /[$`]/.test(path))) {
190        return {
191          deny: `Never pass \`${name}\` a path from a variable or a command substitution: an ` +
192            `empty one deletes from \`/\`, and some forms (\`rm -rf $(…)\`) make Claude Code ` +
193            `stop for approval even in bypass mode. ${LITERAL_PATH}`,
194        }
195      }
196      for (const path of paths) {
197        const kind = criticalKind(path, where)
198        if (kind === undefined) continue
199        return {
200          deny: `Never \`${name}\` \`${path}\`: it is ${kind}, and Claude Code stops for ` +
201            `approval on it even in bypass mode. Delete only what you created. ${LITERAL_PATH}`,
202        }
203      }
204    }
205    if (words[0] === `find` && words[1] === `/`) {
206      return { deny: `Never \`find /\`: search the directory that can hold the answer.` }
207    }
208  }
209  return ALLOW
210}
211
212/**
213 * What a `gh` command posts: nothing, an inline body, a body read from a file, or a body read
214 * from standard input (`--body-file -`, usually a heredoc in the same command).
215 */
216export type GhPost =
217  | { readonly kind: `none` }
218  | { readonly kind: `inline`; readonly body: string }
219  | { readonly kind: `file`; readonly path: string }
220  | { readonly kind: `stdin` }
221
222const POSTING_VERBS = [`create`, `comment`, `edit`, `review`]
223
224/** The value of `flag` in `words`, given as `flag value` or `--long=value`. */
225function flagValue(words: string[], flags: string[]): string | undefined {
226  for (let i = 0; i < words.length; i++) {
227    const word = words[i] ?? ``
228    if (flags.includes(word)) return words[i + 1] ?? ``
229    const long = flags.find((flag) => flag.startsWith(`--`) && word.startsWith(`${flag}=`))
230    if (long) return word.slice(long.length + 1)
231  }
232  return undefined
233}
234
235/**
236 * Finds the body a `gh issue|pr create|comment|edit|review` command posts, reading only that
237 * command's own words. One with no body flag (a title-only edit, `--fill`) posts none this mod
238 * can check.
239 */
240export function ghPost(command: string): GhPost {
241  for (const words of commandsOf(command)) {
242    const [gh, noun, verb] = words
243    if (gh !== `gh` || (noun !== `issue` && noun !== `pr`)) continue
244    if (!POSTING_VERBS.includes(verb ?? ``)) continue
245    const path = flagValue(words, [`--body-file`, `-F`])
246    if (path === `-`) return { kind: `stdin` }
247    if (path !== undefined) return { kind: `file`, path }
248    const body = flagValue(words, [`--body`, `-b`])
249    if (body !== undefined) return { kind: `inline`, body }
250  }
251  return { kind: `none` }
252}
253
254/** Judges a body we are about to post: it must carry the agent marker. */
255export function markerVerdict(text: string): Verdict {
256  return text.includes(AGENT_MARKER) ? ALLOW : {
257    deny: `Start every comment, issue and PR body with \`${AGENT_MARKER}\`: a comment ` +
258      `without it reads as the owner's.`,
259  }
260}
261
262/**
263 * Judges what gitleaks said about a body: exit 0 is clean, 1 is a finding, anything else is a
264 * failed scan. Both of the last refuse: a secret-bearing send fails closed.
265 */
266export function leakVerdict(exitCode: number): Verdict {
267  if (exitCode === 0) return ALLOW
268  return {
269    deny: exitCode === 1
270      ? `gitleaks found a secret in this body. Replace it with <REDACTED:KIND> before posting.`
271      : `The gitleaks scan of this body failed (exit ${exitCode}), so it was not posted.`,
272  }
273}
274