monster-orchestrator 3.4.0 → 3.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ // 席の PreToolUse(Bash) hook(箱 n=2320・n=2311 の直し)。席が艦隊の tmux サーバを消す命令を撃つのを止める。
3
+ //
4
+ // 🔬 なぜ要るか(2026-10-02 17:54 実害): 席が試験で `TMUX_TMPDIR=… tmux kill-server` を撃ち、
5
+ // **$TMUX が TMUX_TMPDIR より優先されて**艦隊サーバに当たり 13 席が同時に落ちた(2026-09-25 に続き 2 回目)。
6
+ // tmux は -S/-L が無いと $TMUX の socket を、$TMUX も無ければ `${TMUX_TMPDIR:-/tmp}/tmux-<uid>/default` を使う。
7
+ //
8
+ // 止める物: ① 行き先(-S/-L)の無い tmux kill-server(`tmux-gui.sh kill-server` を含む)
9
+ // ② pkill/killall の tmux
10
+ // ③ 行き先を明示していても、それが席自身のサーバ($TMUX の 1 欄目)か既定サーバを指す kill-server
11
+ // (行き先が変数などで読めないときも止める=fail-safe。消すのは使い捨てなので字で書ける)
12
+ // 通す物: それ以外の全部(他のサーバを指す kill-server・kill-session 等の他の tmux 命令・tmux を含まない命令)
13
+ //
14
+ // 入出力(Claude Code の PreToolUse 規約): stdin の JSON(tool_name/tool_input.command/cwd)を読み、
15
+ // 止めるときは exit 2+stderr に理由。それ以外は exit 0。読めない・壊れた入力も exit 0(fail-open・席を止めない)。
16
+ // ⛔ python にしない(会員の機体に python3 がある保証は無い・node は orchestrator が必ず持つ)。
17
+
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const GUIDE =
23
+ "使い捨ての tmux は `env -u TMUX tmux -S /tmp/<短い名>.sock …` で立て、同じ -S で消せ。手順: {CTOリポ}/05手順/使い捨てのtmuxの立て方.md";
24
+
25
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
26
+ // tmux の大域オプションのうち引数を取るもの(tmux 3.x: -c shell-command -f file -L socket-name -S socket-path -T features)
27
+ const TMUX_OPTS_WITH_ARG = new Set(["c", "f", "L", "S", "T"]);
28
+
29
+ /**
30
+ * シェルの 1 行を「文(単語の列)」に割る。引用(' " \)を解き、; & | && || 改行 ( ) で文を切る。
31
+ * `$(…)` と `` `…` `` の中身は別の命令として nested に返す(引用 " の中でも実行されるので拾う。' の中は拾わない)。
32
+ * 閉じていない引用は残りを字として扱う(bash はその行を実行しないので、ここで止める理由は無い)。
33
+ * @param {string} src
34
+ * @returns {{ statements: string[][]; nested: string[] }}
35
+ */
36
+ export function splitShell(src) {
37
+ /** @type {string[][]} */
38
+ const statements = [];
39
+ /** @type {string[]} */
40
+ const nested = [];
41
+ /** @type {string[]} */
42
+ let words = [];
43
+ let word = "";
44
+ let inWord = false;
45
+ const endWord = () => {
46
+ if (inWord) words.push(word);
47
+ word = "";
48
+ inWord = false;
49
+ };
50
+ const endStatement = () => {
51
+ endWord();
52
+ if (words.length > 0) statements.push(words);
53
+ words = [];
54
+ };
55
+ // `$(` の直後から対応する `)` までを返す(引用と入れ子を数える)
56
+ const readSubst = (start) => {
57
+ let depth = 1;
58
+ let j = start;
59
+ let q = "";
60
+ for (; j < src.length; j++) {
61
+ const c = src[j];
62
+ if (q === "'") {
63
+ if (c === "'") q = "";
64
+ } else if (q === '"') {
65
+ if (c === "\\") j++;
66
+ else if (c === '"') q = "";
67
+ } else if (c === "\\") j++;
68
+ else if (c === "'" || c === '"') q = c;
69
+ else if (c === "(") depth++;
70
+ else if (c === ")" && --depth === 0) break;
71
+ }
72
+ return { body: src.slice(start, j), end: j };
73
+ };
74
+ const readBacktick = (start) => {
75
+ let j = start;
76
+ while (j < src.length && src[j] !== "`") {
77
+ if (src[j] === "\\") j++;
78
+ j++;
79
+ }
80
+ return { body: src.slice(start, j), end: j };
81
+ };
82
+
83
+ for (let i = 0; i < src.length; i++) {
84
+ const c = src[i];
85
+ if (c === "\\") {
86
+ if (src[i + 1] === "\n") {
87
+ i++; // 行の継続
88
+ continue;
89
+ }
90
+ word += src[i + 1] ?? "";
91
+ inWord = true;
92
+ i++;
93
+ } else if (c === "'") {
94
+ const j = src.indexOf("'", i + 1);
95
+ const end = j === -1 ? src.length : j;
96
+ word += src.slice(i + 1, end);
97
+ inWord = true;
98
+ i = end;
99
+ } else if (c === '"') {
100
+ inWord = true;
101
+ let j = i + 1;
102
+ for (; j < src.length && src[j] !== '"'; j++) {
103
+ if (src[j] === "\\" && j + 1 < src.length && '"\\$`\n'.includes(src[j + 1])) {
104
+ if (src[j + 1] !== "\n") word += src[j + 1];
105
+ j++;
106
+ } else if (src[j] === "$" && src[j + 1] === "(") {
107
+ const s = readSubst(j + 2);
108
+ nested.push(s.body);
109
+ word += "$(" + s.body + ")";
110
+ j = s.end;
111
+ } else if (src[j] === "`") {
112
+ const s = readBacktick(j + 1);
113
+ nested.push(s.body);
114
+ word += "`" + s.body + "`";
115
+ j = s.end;
116
+ } else word += src[j];
117
+ }
118
+ i = j;
119
+ } else if (c === "$" && src[i + 1] === "(") {
120
+ const s = readSubst(i + 2);
121
+ nested.push(s.body);
122
+ word += "$(" + s.body + ")";
123
+ inWord = true;
124
+ i = s.end;
125
+ } else if (c === "`") {
126
+ const s = readBacktick(i + 1);
127
+ nested.push(s.body);
128
+ word += "`" + s.body + "`";
129
+ inWord = true;
130
+ i = s.end;
131
+ } else if (c === ";" || c === "&" || c === "|" || c === "\n" || c === "(" || c === ")") {
132
+ endStatement();
133
+ } else if (c === " " || c === "\t") {
134
+ endWord();
135
+ } else if (c === "#" && !inWord) {
136
+ // 単語の頭の # から行末まではコメント
137
+ const j = src.indexOf("\n", i);
138
+ i = j === -1 ? src.length : j - 1;
139
+ } else {
140
+ word += c;
141
+ inWord = true;
142
+ }
143
+ }
144
+ endStatement();
145
+ return { statements, nested };
146
+ }
147
+
148
+ const ASSIGN = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
149
+
150
+ /** `$VAR`・`${VAR}`・頭の `~` を展開する。展開できない物(未定義の変数・$(…)・`…`)が残れば null。 */
151
+ function expand(value, vars, env) {
152
+ if (/`|\$\(/.test(value)) return null;
153
+ let unresolved = false;
154
+ let out = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_m, a, b) => {
155
+ const name = a ?? b;
156
+ const v = name in vars ? vars[name] : env[name];
157
+ if (v === undefined) {
158
+ unresolved = true;
159
+ return "";
160
+ }
161
+ return v;
162
+ });
163
+ if (unresolved || out.includes("$")) return null;
164
+ if (out === "~" || out.startsWith("~/")) {
165
+ if (!env.HOME) return null;
166
+ out = env.HOME + out.slice(1);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** socket の道を比べられる形にする(macOS の /tmp → /private/tmp を realpath で揃える)。 */
172
+ function canonical(p, cwd, realpath) {
173
+ const abs = path.resolve(cwd, p);
174
+ const dir = path.dirname(abs);
175
+ let real = dir;
176
+ try {
177
+ real = realpath(dir);
178
+ } catch {
179
+ // 無い道は字のまま比べる
180
+ }
181
+ return path.join(real, path.basename(abs));
182
+ }
183
+
184
+ /**
185
+ * tmux 呼び出しの引数から、kill-server を撃つか・行き先は何かを読む。
186
+ * tmux は `\;` で命令を続けられるので、全ての命令を見る。命令名は一意な前方一致も効く(kill-ser 以上)。
187
+ * @returns {{ kills: boolean; socket?: string; label?: string }}
188
+ */
189
+ export function readTmuxArgs(args) {
190
+ /** @type {{ kills: boolean; socket?: string; label?: string }} */
191
+ const r = { kills: false };
192
+ let i = 0;
193
+ for (; i < args.length; i++) {
194
+ const a = args[i];
195
+ if (a === "--") {
196
+ i++;
197
+ break;
198
+ }
199
+ if (!a.startsWith("-") || a === "-") break;
200
+ // 束ねたフラグ(-2u)と、引数つきの直付け(-L名・-S/道)を読む
201
+ for (let k = 1; k < a.length; k++) {
202
+ const f = a[k];
203
+ if (TMUX_OPTS_WITH_ARG.has(f)) {
204
+ const val = k + 1 < a.length ? a.slice(k + 1) : args[++i];
205
+ if (f === "S") r.socket = val ?? "";
206
+ if (f === "L") r.label = val ?? "";
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ let atCommand = true;
212
+ for (; i < args.length; i++) {
213
+ let a = args[i];
214
+ const ends = a.length > 1 && a.endsWith(";");
215
+ if (a === ";") {
216
+ atCommand = true;
217
+ continue;
218
+ }
219
+ if (ends) a = a.slice(0, -1);
220
+ if (atCommand && a.length >= "kill-ser".length && "kill-server".startsWith(a)) r.kills = true;
221
+ atCommand = ends;
222
+ }
223
+ return r;
224
+ }
225
+
226
+ /**
227
+ * Bash の命令 1 本を判定する。止めるなら理由(日本語 1 文)、通すなら null。
228
+ * @param {string} command
229
+ * @param {{ env?: Record<string, string | undefined>; cwd?: string; uid?: number; realpath?: (p: string) => string }} [ctx]
230
+ * @returns {string | null}
231
+ */
232
+ export function verdict(command, ctx = {}) {
233
+ const env = ctx.env ?? process.env;
234
+ const cwd = ctx.cwd ?? process.cwd();
235
+ const uid = ctx.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
236
+ const realpath = ctx.realpath ?? ((p) => fs.realpathSync.native(p));
237
+ return judge(command, { env, cwd, uid, realpath, vars: {} }, 0);
238
+ }
239
+
240
+ function judge(command, c, depth) {
241
+ if (typeof command !== "string" || command.length === 0 || depth > 4) return null;
242
+ const { statements, nested } = splitShell(command);
243
+ for (const sub of nested) {
244
+ const why = judge(sub, c, depth + 1);
245
+ if (why) return why;
246
+ }
247
+ for (const words of statements) {
248
+ const why = judgeStatement(words, c, depth);
249
+ if (why) return why;
250
+ }
251
+ return null;
252
+ }
253
+
254
+ function judgeStatement(words, c, depth) {
255
+ // 頭の代入(`X=1 cmd` の一時代入と、代入だけの文=以後の行で効くシェル変数)
256
+ /** @type {Record<string, string>} */
257
+ const prefix = {};
258
+ let k = 0;
259
+ while (k < words.length && ASSIGN.test(words[k])) {
260
+ const [, name, value] = /** @type {RegExpMatchArray} */ (words[k].match(ASSIGN));
261
+ prefix[name] = expand(value, c.vars, c.env) ?? "";
262
+ k++;
263
+ }
264
+ if (k === words.length) {
265
+ Object.assign(c.vars, prefix);
266
+ return null;
267
+ }
268
+ if (words[k] === "export") {
269
+ for (const w of words.slice(k + 1)) {
270
+ const m = w.match(ASSIGN);
271
+ if (m) c.vars[m[1]] = expand(m[2], c.vars, c.env) ?? "";
272
+ }
273
+ return null;
274
+ }
275
+
276
+ const base = words.map((w) => path.basename(w));
277
+
278
+ // ② pkill/killall の tmux
279
+ const killer = base.findIndex((b) => b === "pkill" || b === "killall");
280
+ if (killer !== -1 && words.slice(killer + 1).some((w) => w.includes("tmux"))) {
281
+ return "pkill/killall の tmux は艦隊の tmux サーバを巻き込む";
282
+ }
283
+
284
+ // bash -c '…'・eval … の中身も命令として見る
285
+ const sh = base.findIndex((b) => SHELLS.has(b));
286
+ if (sh !== -1) {
287
+ const ci = words.indexOf("-c", sh + 1);
288
+ if (ci !== -1 && words[ci + 1] !== undefined) {
289
+ const why = judge(words[ci + 1], c, depth + 1);
290
+ if (why) return why;
291
+ }
292
+ }
293
+ if (base[k] === "eval") {
294
+ const why = judge(words.slice(k + 1).join(" "), c, depth + 1);
295
+ if (why) return why;
296
+ }
297
+
298
+ const t = base.findIndex((b) => b === "tmux" || b === "tmux-gui.sh");
299
+ if (t === -1) return null;
300
+ const r = readTmuxArgs(words.slice(t + 1));
301
+ if (!r.kills) return null;
302
+ if (base[t] === "tmux-gui.sh") {
303
+ return "tmux-gui.sh kill-server は艦隊の tmux サーバそのものを消す";
304
+ }
305
+
306
+ // この呼び出しで効く環境(`env -u TMUX`・一時代入・export を重ねる)
307
+ /** @type {Record<string, string | undefined>} */
308
+ const eff = { ...c.env, ...c.vars, ...prefix };
309
+ for (let j = k; j < t; j++) {
310
+ if (base[j] === "env") {
311
+ for (let m = j + 1; m < t; m++) {
312
+ if (words[m] === "-u" && words[m + 1] !== undefined) eff[words[++m]] = undefined;
313
+ else if (words[m].startsWith("--unset=")) eff[words[m].slice(8)] = undefined;
314
+ else if (ASSIGN.test(words[m])) {
315
+ const [, n, v] = /** @type {RegExpMatchArray} */ (words[m].match(ASSIGN));
316
+ eff[n] = expand(v, c.vars, c.env) ?? "";
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ if (r.socket === undefined && r.label === undefined) {
323
+ return "行き先(-S <socket> か -L <名>)の無い tmux kill-server($TMUX が在ると TMUX_TMPDIR は効かず席自身のサーバに当たる・無ければ既定サーバに当たる)";
324
+ }
325
+
326
+ // ③ 行き先が席自身のサーバ/既定サーバか
327
+ const tmpdirOf = (e) => (e.TMUX_TMPDIR && e.TMUX_TMPDIR.length > 0 ? e.TMUX_TMPDIR : "/tmp");
328
+ let target;
329
+ if (r.socket !== undefined) {
330
+ const p = expand(r.socket, c.vars, c.env);
331
+ if (p === null || p === "") return "kill-server の -S の行き先が読めない(変数など)。使い捨ての socket は字で書け";
332
+ target = canonical(p, c.cwd, c.realpath);
333
+ } else {
334
+ const name = expand(/** @type {string} */ (r.label), c.vars, c.env);
335
+ if (name === null || name === "") return "kill-server の -L の名が読めない(変数など)。使い捨ての socket は -S と字で書け";
336
+ target = canonical(path.join(tmpdirOf(eff), `tmux-${c.uid}`, name), c.cwd, c.realpath);
337
+ }
338
+ /** @type {string[]} */
339
+ const protectedSockets = [];
340
+ const seat = (c.env.TMUX ?? "").split(",")[0];
341
+ if (seat) protectedSockets.push(canonical(seat, c.cwd, c.realpath));
342
+ protectedSockets.push(canonical(path.join(tmpdirOf(c.env), `tmux-${c.uid}`, "default"), c.cwd, c.realpath));
343
+ if (protectedSockets.includes(target)) {
344
+ return `kill-server の行き先(${target})が席自身の tmux サーバ(艦隊)を指している`;
345
+ }
346
+ return null;
347
+ }
348
+
349
+ /** 拒否文。 */
350
+ export function denyMessage(reason) {
351
+ return `⛔ tmux-guard(n=2320): ${reason}。${GUIDE}`;
352
+ }
353
+
354
+ /**
355
+ * hook の本体。stdin の文字列を受けて終了コードを返す(0=通す/2=止める)。
356
+ * @param {string} raw
357
+ * @param {{ env?: Record<string, string | undefined>; uid?: number; realpath?: (p: string) => string; stderr?: (s: string) => void }} [ctx]
358
+ */
359
+ export function runHook(raw, ctx = {}) {
360
+ let reason = null;
361
+ try {
362
+ const data = JSON.parse(raw);
363
+ if (!data || data.tool_name !== "Bash") return 0;
364
+ const command = data.tool_input?.command;
365
+ if (typeof command !== "string") return 0;
366
+ reason = verdict(command, {
367
+ env: ctx.env,
368
+ cwd: typeof data.cwd === "string" && data.cwd ? data.cwd : undefined,
369
+ uid: ctx.uid,
370
+ realpath: ctx.realpath,
371
+ });
372
+ } catch {
373
+ return 0; // 読めない入力で席を止めない
374
+ }
375
+ if (!reason) return 0;
376
+ (ctx.stderr ?? ((s) => process.stderr.write(s)))(denyMessage(reason) + "\n");
377
+ return 2;
378
+ }
379
+
380
+ function isMain() {
381
+ try {
382
+ return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+
388
+ if (isMain()) {
389
+ let raw = "";
390
+ try {
391
+ raw = fs.readFileSync(0, "utf8");
392
+ } catch {
393
+ process.exit(0);
394
+ }
395
+ process.exit(runHook(raw));
396
+ }