SLOPSHOPPER

cluster-guard

Guard rails for home-cluster: rendered files, secrets files, the UniFi update window and sops churn

newguardtoaststatusprocess
A shopper browsing a rack in a slop shop
README

cluster-guard

A Claude Code hooks module for this repo. It refuses four things before the tool runs:

  • Rendered files. An Edit or Write under bootstrap/, kubernetes/ or talos/, or on the root .sops.yaml, is refused with a pointer to the .j2 source. So is a Bash command that writes there: a redirect target, a file given to tee, the destination of cp, install or rsync (or either end of mv, which removes its source), or a file given to sd, sed -i, perl -pi, yq -i/--inplace or sops -i/--in-place, in relative, ./, $PWD or absolute form, quoted or not. Reading a rendered file and sending the result elsewhere is allowed.
  • Secrets files. A Bash command segment that names cluster.toml, age.key, deploy.key, cloudflare-tunnel.json, flux-webhook-token.txt, kubeconfig or talosconfig is refused unless that segment runs template/scripts/validate.py, template/scripts/check_layout.py, a just template recipe or taplo check. Segments are split on ;, &&, ||, | and newlines, and quoted text is ignored, so a commit message or a quoted grep pattern may name the file. The Read tool is denied cluster.toml and the key files by .claude/settings.json.
  • The UniFi window. Node maintenance is refused from 02:45 to 03:30 local time. That covers just talos apply and the drain, stop, reboot, upgrade and reset recipes, talosctl with upgrade, reset, apply-config, reboot or shutdown, topf with apply, upgrade or reset, and kubectl drain.
  • sops churn. After just configure, git add, git commit, git restore, git stash or git checkout, it counts the modified *.sops.* files under the three rendered directories and shows the count on a pinned line, or clears it. A commit that carries them (staged, or any with -a) also raises a toast.

Checks

claude plugin validate .claude/skills/cluster-guard
claude plugin test .claude/skills/cluster-guard
tsc -p .claude/skills/cluster-guard

tsc -p needs .claude-plugin/types/, which the engine lays beside a mod it hot-reloads and which is gitignored. A /reload-plugins on 2.1.296 did not lay it (measured 2026-10-10). Without it, copy the declarations from the bundled plugin-authoring skill into a scratch folder and point a tsconfig at them and at hooks/ and tests/, as the probe under docs/superpowers/specs/2026-10-10-mods-probe/tsconfig.external.json does.

Source 1 files
hooks/register.ts 184 lines
1import type { Register } from "claude-code";
2
3const DIRS = ["bootstrap", "kubernetes", "talos"];
4const DIR = `(?:${DIRS.join("|")})/`;
5const RENDERED = new RegExp(`^(?:${DIR}|\\.sops\\.yaml$)`);
6const REDIRECT = /^\d*>>?$/;
7// A combined short-flag group that carries `i`: sed -i, sed -Ei, sed -i.bak, perl -pi.
8const INPLACE_SHORT = /^-[a-zA-Z]*i/;
9const SOPS_PATHSPEC = DIRS.map((d) => `:(top,glob)${d}/**/*.sops.*`);
10const NODE_OPS =
11  /\bjust\s+talos\s+(?:apply|drain|stop-node|reboot-node|upgrade-node|apply-node|reset-node|upgrade-k8s)\b|\btalosctl\b.*\b(?:upgrade|upgrade-k8s|reset|apply-config|reboot|shutdown)\b|\btopf\s+(?:apply|upgrade|reset)\b|\bkubectl\b.*\bdrain\b/;
12const SECRET_FILES =
13  /\b(?:cluster\.toml|age\.key|deploy\.key|cloudflare-tunnel\.json|flux-webhook-token\.txt|kubeconfig|talosconfig)\b/;
14const SECRET_READERS =
15  /template\/scripts\/(?:validate|check_layout)\.py|\bjust\s+template\b|\btaplo\s+check\b/;
16const TRIGGER =
17  /\bjust\s+(?:template\s+)?configure\b|\bgit\s+(?:commit|add|restore|stash|checkout)\b/;
18const COMMIT = /\bgit\s+commit\b/;
19const COMMIT_ALL = /\bgit\s+commit\b.*\s(?:-[a-zA-Z]*a[a-zA-Z]*|--all)(?:\s|$)/;
20const CHURNED = /^(?:M.|.M) /;
21const STAGED = /^M/;
22
23function inWindow(ms: number): boolean {
24  const d = new Date(ms);
25  const m = d.getHours() * 60 + d.getMinutes();
26  return m >= 2 * 60 + 45 && m <= 3 * 60 + 30;
27}
28
29// Quoted text is a message or a pattern, not a file the command opens.
30function segments(cmd: string): string[] {
31  return cmd.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '""').split(/\|\||&&|[;|\n]/);
32}
33
34// The same split, with quoted text kept, for the write check: a quoted destination is
35// still a destination. Separators inside quotes are masked so they do not split.
36function rawSegments(cmd: string): string[] {
37  const masked = cmd.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, (q) =>
38    q.replace(/[;|&\n]/g, (c) => `\u0001${c.charCodeAt(0)}\u0001`),
39  );
40  return masked
41    .split(/\|\||&&|[;|\n]/)
42    .map((s) => s.replace(/\u0001(\d+)\u0001/g, (_, n: string) => String.fromCharCode(Number(n))));
43}
44
45// Shell words of one segment with quotes removed, stopping at a comment. A redirect
46// glued to its target (`>file`) is split so the target is its own word.
47function words(segment: string): string[] {
48  const out: string[] = [];
49  const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
50  let m: RegExpExecArray | null;
51  while ((m = re.exec(segment.replace(/(\d*>>?)(?=[^\s>&|])/g, "$1 ")))) {
52    const w = m[1] ?? m[2] ?? m[3];
53    if (m[3] !== undefined && w.startsWith("#")) break;
54    out.push(w);
55  }
56  return out;
57}
58
59// Every word a segment writes to: redirect targets, tee's files, the destination of
60// cp (and both ends of mv, which removes its source), and the files of an in-place
61// editor. Reading a rendered file and sending the result elsewhere writes none of these.
62function writtenWords(ws: string[]): string[] {
63  const out: string[] = [];
64  ws.forEach((w, i) => {
65    if (REDIRECT.test(w) && ws[i + 1]) out.push(ws[i + 1]);
66  });
67  const start = ws.findIndex((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
68  if (start < 0) return out;
69  const base = ws[start].replace(/^.*\//, "");
70  const args = ws.slice(start + 1);
71  const operands = args.filter(
72    (w, i) => !w.startsWith("-") && !REDIRECT.test(w) && !REDIRECT.test(args[i - 1] ?? ""),
73  );
74  const has = (re: RegExp) => args.some((w) => re.test(w));
75  switch (base) {
76    case "tee":
77    case "mv":
78      out.push(...operands);
79      break;
80    case "cp":
81    case "install":
82    case "rsync":
83      if (operands.length) out.push(operands[operands.length - 1]);
84      break;
85    case "sd":
86      out.push(...operands.slice(2));
87      break;
88    case "sed":
89    case "perl":
90      if (has(INPLACE_SHORT) || has(/^--in-place/)) out.push(...operands);
91      break;
92    case "yq":
93      if (has(/^(?:-i|--inplace)$/)) out.push(...operands);
94      break;
95    case "sops":
96      if (has(/^(?:-i|--in-place)$/)) out.push(...operands);
97      break;
98  }
99  return out;
100}
101
102// A word names a rendered file when, relative to the repo, it is under one of the
103// rendered directories or is the root .sops.yaml. Absolute, $PWD and ./ forms count.
104function isRendered(word: string, cwd: string): boolean {
105  let p = word;
106  for (const prefix of [`${cwd}/`, "$PWD/", "${PWD}/", "./"]) {
107    if (p.startsWith(prefix)) {
108      p = p.slice(prefix.length);
109      break;
110    }
111  }
112  return RENDERED.test(p.replace(/^.*\/home-cluster\//, ""));
113}
114
115function writesRendered(cmd: string, cwd: string): boolean {
116  return rawSegments(cmd).some((s) => writtenWords(words(s)).some((w) => isRendered(w, cwd)));
117}
118
119function count(porcelain: string, re: RegExp): number {
120  return porcelain.split("\n").filter((l) => re.test(l)).length;
121}
122
123export const register: Register = (on) => {
124  on("tool.call", { tool: ["Edit", "Write"] }, async ($, e, next) => {
125    const cwd = await $.session.cwd();
126    const rel = e.file_path.startsWith(`${cwd}/`)
127      ? e.file_path.slice(cwd.length + 1)
128      : e.file_path.replace(/^.*\/home-cluster\//, "");
129    if (RENDERED.test(rel)) {
130      return {
131        deny: `${rel} is rendered by just configure; edit the .j2 source under template/config/ instead`,
132      };
133    }
134    return next(e);
135  }).catch(($, e, next) =>
136    next.called ? next(e) : { deny: "cluster-guard could not check the path" },
137  );
138
139  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
140    const cmd = e.command;
141    if (NODE_OPS.test(cmd) && inWindow(await $.clock.now())) {
142      return {
143        deny: "node maintenance is blocked 02:45 to 03:30 local: the UniFi gear auto-updates at 03:00 and a switch reboot mid-drain loses etcd quorum",
144      };
145    }
146    const parts = segments(cmd);
147    if (parts.some((s) => SECRET_FILES.test(s) && !SECRET_READERS.test(s))) {
148      return {
149        deny: "that command names a secrets file; only template/scripts/validate.py, check_layout.py, just template recipes and taplo check may read one (quote the name if it is only text)",
150      };
151    }
152    const cwd = await $.session.cwd();
153    if (writesRendered(cmd, cwd)) {
154      return {
155        deny: "that writes a rendered file; edit the .j2 source under template/config/ and run just configure",
156      };
157    }
158    if (!TRIGGER.test(cmd)) return next(e);
159
160    const status = async () =>
161      (
162        await $.process.run(
163          ["git", "status", "--porcelain", "--untracked-files=no", "--", ...SOPS_PATHSPEC],
164          { cwd },
165        )
166      ).stdout;
167    // Counted before the commit runs, because afterwards the committed files are clean.
168    const committing = COMMIT.test(cmd)
169      ? count(await status(), COMMIT_ALL.test(cmd) ? CHURNED : STAGED)
170      : 0;
171    const result = await next(e);
172    const n = count(await status(), CHURNED);
173    $.ui.status(
174      n > 0
175        ? `${n} *.sops.* files re-encrypted; restore with git checkout unless the secret inputs changed`
176        : undefined,
177    );
178    if (committing > 0) $.ui.toast(`committing ${committing} re-encrypted sops files`);
179    return result;
180  }).catch(($, e, next) =>
181    next.called ? next(e) : { deny: "cluster-guard could not check the command" },
182  );
183};
184