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

A Claude Code hooks module for this repo. It refuses four things before the tool runs:
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.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.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.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.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.
hooks/register.ts 184 lines1import 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