monster-orchestrator 3.5.0 → 3.7.7
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.
- package/dist-obf/.buildinfo +6 -6
- package/dist-obf/account-failover-tick.js +2 -0
- package/dist-obf/governor-tick.js +1 -1
- package/dist-obf/index.js +1 -1
- package/dist-obf/manifest.json +8 -2
- package/dist-obf/package.json +7 -5
- package/dist-obf/supervisor-notify-tick.js +1 -1
- package/manifest.json +8 -2
- package/package.json +4 -2
- package/scripts/account-failover-tick.sh +47 -0
- package/scripts/monster-deathcheck +165 -13
- package/scripts/tmux-guard.mjs +396 -0
|
@@ -40,7 +40,8 @@ exit code:
|
|
|
40
40
|
MONSTER_ROOT monster レジストリの場所(既定 ~/.claude/monsters)
|
|
41
41
|
TMUX_BIN tmux の絶対パス(既定は PATH → /opt/homebrew/bin/tmux の順に探索)
|
|
42
42
|
⚠️ 非対話 SSH では PATH に /opt/homebrew/bin が乗らないため必要
|
|
43
|
-
CLAUDE_PROJECTS_ROOT transcript の場所(既定 ~/.claude/projects
|
|
43
|
+
CLAUDE_PROJECTS_ROOT transcript の場所(既定 ~/.claude/projects)。足跡が無い席(移行前)だけ読む
|
|
44
|
+
BLUELAMP_FOOTPRINTS_ROOT 足跡の場所(既定 ~/.bluelamp/footprints・箱 n=2475 §足跡の形 v1)
|
|
44
45
|
"""
|
|
45
46
|
|
|
46
47
|
import glob
|
|
@@ -247,11 +248,11 @@ def find_ghosts(monsters, tmux):
|
|
|
247
248
|
# 🚨 GHOST と STALLED は同時に成立しうる(実測: 23b47504)。
|
|
248
249
|
# 片方が他方を隠さないよう、ghost 側にも API 状態を載せる。
|
|
249
250
|
gid = next((k for k in monsters if k.startswith(short)), None) or short
|
|
250
|
-
readable,
|
|
251
|
+
readable, stalled, _cause = read_api_verdict(gid)
|
|
251
252
|
out.append({
|
|
252
253
|
"session": sess,
|
|
253
254
|
"short_id": short,
|
|
254
|
-
"stalled":
|
|
255
|
+
"stalled": stalled if readable else None,
|
|
255
256
|
"stalled_unverified": not readable,
|
|
256
257
|
"spawned_at": spawned.get(sess, ""),
|
|
257
258
|
"detail": ("tmux にセッションが在るが state.json にエントリが無い。"
|
|
@@ -330,6 +331,109 @@ def read_last_api_state(monster_id):
|
|
|
330
331
|
return (False, None, None)
|
|
331
332
|
|
|
332
333
|
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# 足跡(箱 n=2475 §足跡の形 v1)— 席は会話の記録を書かない(CLAUDE_CODE_SKIP_PROMPT_HISTORY=1)。
|
|
336
|
+
# 起動子のフックが本文なしの 1 行を ~/.bluelamp/footprints/<cwd のスラグ>/<sid>.jsonl に足す:
|
|
337
|
+
# {"v":1,"ts":…,"sid":…,"cwd":…,"ev":"api_error","err":"rate_limit"} 等
|
|
338
|
+
# transcript 版と同じ問い(最後の応答は API エラーか)を、本文を持たない足跡で答える:
|
|
339
|
+
# 最後の ev=api_error の後に、席が応答した印(FOOTPRINT_RESPONDED)が 1 つも無ければ止まっている。
|
|
340
|
+
# 🔑 prompt は応答の印に数えない(transcript 版も user 行の後に assistant 行が来るまで最後のエラーを見続ける)。
|
|
341
|
+
# ⚠️ 足跡が在る席は足跡だけを読む。足跡が無い席(移行前)だけ transcript を読む。
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
FOOTPRINTS_ROOT = os.environ.get("BLUELAMP_FOOTPRINTS_ROOT") or os.path.expanduser("~/.bluelamp/footprints")
|
|
345
|
+
|
|
346
|
+
FOOTPRINT_TAIL_LINES = 200
|
|
347
|
+
|
|
348
|
+
# api_error の後に来たら「席が応答した」印になる足跡の種別
|
|
349
|
+
FOOTPRINT_RESPONDED = {"tool", "tool_fail", "stop", "session_start"}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def footprint_dir(monster_id):
|
|
353
|
+
"""monster の足跡のディレクトリ。無ければ None(=移行前の席)。"""
|
|
354
|
+
hits = [d for d in glob.glob(os.path.join(FOOTPRINTS_ROOT, f"*monsters-{monster_id}*")) if os.path.isdir(d)]
|
|
355
|
+
return hits[0] if hits else None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def last_footprint_error(lines):
|
|
359
|
+
"""足跡の行列から、止めている api_error の err を返す純関数(--verify で単体検証する)。
|
|
360
|
+
|
|
361
|
+
返り値: None=止まっていない/文字列=最後の api_error の err(その後に応答の印が無い)
|
|
362
|
+
v が 1 でない行・壊れた行は読まない。
|
|
363
|
+
"""
|
|
364
|
+
last_err = None
|
|
365
|
+
for line in lines:
|
|
366
|
+
line = line.strip()
|
|
367
|
+
if not line:
|
|
368
|
+
continue
|
|
369
|
+
try:
|
|
370
|
+
e = json.loads(line)
|
|
371
|
+
except json.JSONDecodeError:
|
|
372
|
+
continue
|
|
373
|
+
if not isinstance(e, dict) or e.get("v") != 1:
|
|
374
|
+
continue
|
|
375
|
+
ev = e.get("ev")
|
|
376
|
+
if ev == "api_error":
|
|
377
|
+
err = e.get("err")
|
|
378
|
+
last_err = err if isinstance(err, str) and err else "unknown"
|
|
379
|
+
elif ev in FOOTPRINT_RESPONDED:
|
|
380
|
+
last_err = None
|
|
381
|
+
return last_err
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def read_last_footprint_state(monster_id):
|
|
385
|
+
"""足跡から API 状態を返す。
|
|
386
|
+
|
|
387
|
+
返り値: None=足跡が無い(ディレクトリが無い/1 本も無い=移行前の席・transcript へ。
|
|
388
|
+
orchestrator の footprint.ts readSeatFootprintTail と同じ線引き)/(readable, err)
|
|
389
|
+
readable=False は「足跡のファイルは在るが読めない」。⚠️ 正常に丸めない。
|
|
390
|
+
"""
|
|
391
|
+
d = footprint_dir(monster_id)
|
|
392
|
+
if d is None:
|
|
393
|
+
return None
|
|
394
|
+
files = glob.glob(os.path.join(d, "*.jsonl"))
|
|
395
|
+
if not files:
|
|
396
|
+
return None
|
|
397
|
+
try:
|
|
398
|
+
with open(max(files, key=os.path.getmtime), errors="replace") as f:
|
|
399
|
+
lines = f.readlines()[-FOOTPRINT_TAIL_LINES:]
|
|
400
|
+
except OSError:
|
|
401
|
+
return (False, None)
|
|
402
|
+
return (True, last_footprint_error(lines))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def classify_footprint_error(err):
|
|
406
|
+
"""足跡の err から判定名を返す純関数(--verify で単体検証する)。
|
|
407
|
+
|
|
408
|
+
transcript 版(classify_api_error)と同じ 3 つに畳む:
|
|
409
|
+
rate_limit(transcript では 429)→ STALLED-RATE-LIMIT
|
|
410
|
+
server_error・overloaded(transcript では 5xx・529 Overloaded)→ STALLED-BACKEND
|
|
411
|
+
(err の語は StopFailure の error=箱 n=2475 の W1 実測・Claude Code 2.1.289 の列挙)
|
|
412
|
+
それ以外(authentication_failed 等・transcript では status なし)→ STALLED-API-ERROR
|
|
413
|
+
"""
|
|
414
|
+
if not err:
|
|
415
|
+
return None
|
|
416
|
+
if err == "rate_limit":
|
|
417
|
+
return "STALLED-RATE-LIMIT"
|
|
418
|
+
if err in ("server_error", "overloaded"):
|
|
419
|
+
return "STALLED-BACKEND"
|
|
420
|
+
return "STALLED-API-ERROR"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def read_api_verdict(monster_id):
|
|
424
|
+
"""席の API 応答不能の判定。足跡が在れば足跡だけ、無ければ transcript(移行の間)。
|
|
425
|
+
|
|
426
|
+
返り値 (readable, verdict, cause)
|
|
427
|
+
verdict は STALLED-* か None。cause は詳細表示用("HTTP 429" / "err=rate_limit")。
|
|
428
|
+
"""
|
|
429
|
+
fp = read_last_footprint_state(monster_id)
|
|
430
|
+
if fp is not None:
|
|
431
|
+
readable, err = fp
|
|
432
|
+
return (readable, classify_footprint_error(err) if readable else None, f"err={err}")
|
|
433
|
+
readable, is_err, status = read_last_api_state(monster_id)
|
|
434
|
+
return (readable, classify_api_error(is_err, status) if readable else None, f"HTTP {status}")
|
|
435
|
+
|
|
436
|
+
|
|
333
437
|
def classify_api_error(is_api_error, status):
|
|
334
438
|
"""API エラー状態から判定名を返す純関数(--verify で単体検証する)。
|
|
335
439
|
|
|
@@ -345,15 +449,16 @@ def classify_api_error(is_api_error, status):
|
|
|
345
449
|
return "STALLED-API-ERROR"
|
|
346
450
|
|
|
347
451
|
|
|
348
|
-
def stalled_detail(verdict,
|
|
452
|
+
def stalled_detail(verdict, cause):
|
|
453
|
+
"""cause は詳細表示用の原因(transcript なら "HTTP 503"・足跡なら "err=server_error")。"""
|
|
349
454
|
if verdict == "STALLED-RATE-LIMIT":
|
|
350
|
-
return ("API が上限で応答しない(
|
|
455
|
+
return (f"API が上限で応答しない({cause})。state.json は running・tmux も在席・"
|
|
351
456
|
"session.log も更新され続けるが、次の 1 文字も出力できない。"
|
|
352
457
|
"盤面の稼働数が嘘になるので、配車先として数えないこと")
|
|
353
458
|
if verdict == "STALLED-BACKEND":
|
|
354
|
-
return (f"API がバックエンド障害で応答しない(
|
|
355
|
-
"
|
|
356
|
-
return f"API が応答しない(
|
|
459
|
+
return (f"API がバックエンド障害で応答しない({cause})。"
|
|
460
|
+
"上限と違い、次の瞬間に回復しうる")
|
|
461
|
+
return f"API が応答しない({cause})"
|
|
357
462
|
|
|
358
463
|
def tail_since_last_prompt(events):
|
|
359
464
|
"""最後の user_prompt / spawned 以降のイベント列を切り出す"""
|
|
@@ -383,15 +488,14 @@ def classify(mid, rec, events_by_id, tmux):
|
|
|
383
488
|
"state.json は running だが tmux セッションが3回とも取れない")
|
|
384
489
|
|
|
385
490
|
# tmux に居ても API が応答しないことがある。
|
|
386
|
-
readable,
|
|
491
|
+
readable, stalled, cause = read_api_verdict(mid)
|
|
387
492
|
if not readable:
|
|
388
493
|
# ⚠️ 読めなかったものを ALIVE に丸めない(正常と未確認は別物)
|
|
389
494
|
return ("ALIVE-UNVERIFIED",
|
|
390
495
|
"tmux セッション在席を実測(3回確認)/"
|
|
391
|
-
"⚠️ transcript
|
|
392
|
-
stalled = classify_api_error(is_err, status)
|
|
496
|
+
"⚠️ 足跡も transcript も読めず、API に応答できるかは未確認")
|
|
393
497
|
if stalled:
|
|
394
|
-
return (stalled, stalled_detail(stalled,
|
|
498
|
+
return (stalled, stalled_detail(stalled, cause))
|
|
395
499
|
|
|
396
500
|
return ("ALIVE", "tmux セッション在席を実測(3回確認)")
|
|
397
501
|
|
|
@@ -555,6 +659,52 @@ def verify_stalled():
|
|
|
555
659
|
return ok
|
|
556
660
|
|
|
557
661
|
|
|
662
|
+
def verify_footprint():
|
|
663
|
+
"""足跡の判定の単体検証と、付け替え前後(transcript/足跡)が同じ判定を返すかの表(箱 n=2475)。
|
|
664
|
+
|
|
665
|
+
🚨 偽陽性の方が危険(働いている席を STALLED と誤ると「止めてよい」に見える)。
|
|
666
|
+
"""
|
|
667
|
+
def fp(ev, **kw):
|
|
668
|
+
return json.dumps({"v": 1, "ts": "2026-10-04T08:00:00Z", "sid": "s", "cwd": "/x", "ev": ev, **kw})
|
|
669
|
+
|
|
670
|
+
# (場面, transcript の (is_api_error, status), 足跡の行列)
|
|
671
|
+
scenes = [
|
|
672
|
+
("rate_limit(上限)", (True, 429), [fp("prompt"), fp("tool", tool="Bash", ok=True), fp("api_error", err="rate_limit")]),
|
|
673
|
+
("authentication_failed(失効)", (True, None), [fp("prompt"), fp("api_error", err="authentication_failed")]),
|
|
674
|
+
("server_error(バックエンド)", (True, 500), [fp("prompt"), fp("api_error", err="server_error")]),
|
|
675
|
+
("overloaded(バックエンド)", (True, 529), [fp("prompt"), fp("api_error", err="overloaded")]),
|
|
676
|
+
("billing_error(その他)", (True, 400), [fp("prompt"), fp("api_error", err="billing_error")]),
|
|
677
|
+
("正常", (False, None), [fp("prompt"), fp("tool", tool="Bash", ok=True), fp("stop")]),
|
|
678
|
+
("上限の後に回復", (False, None), [fp("api_error", err="rate_limit"), fp("prompt"), fp("tool", tool="Bash", ok=True)]),
|
|
679
|
+
("上限の後に続きを投げたが未応答", (True, 429), [fp("api_error", err="rate_limit"), fp("prompt")]),
|
|
680
|
+
("配下の席の終わり・pane の終わりは応答でない", (True, 429), [fp("api_error", err="rate_limit"), fp("subagent_stop"), fp("session_end")]),
|
|
681
|
+
]
|
|
682
|
+
ok = True
|
|
683
|
+
seen = set()
|
|
684
|
+
for label, (is_err, status), lines in scenes:
|
|
685
|
+
before = classify_api_error(is_err, status)
|
|
686
|
+
after = classify_footprint_error(last_footprint_error(lines))
|
|
687
|
+
seen.add(after)
|
|
688
|
+
good = before == after
|
|
689
|
+
ok = ok and good
|
|
690
|
+
print(f" {'OK ' if good else 'NG '}{label:<28} transcript={before} 足跡={after}")
|
|
691
|
+
# 壊れた行・v が違う行は読まない
|
|
692
|
+
for label, lines, want in [
|
|
693
|
+
("壊れた行", ["{壊れた", ""], None),
|
|
694
|
+
("v が 1 でない", [json.dumps({"v": 2, "ev": "api_error", "err": "rate_limit"})], None),
|
|
695
|
+
("空", [], None),
|
|
696
|
+
]:
|
|
697
|
+
got = classify_footprint_error(last_footprint_error(lines))
|
|
698
|
+
good = got == want
|
|
699
|
+
ok = ok and good
|
|
700
|
+
print(f" {'OK ' if good else 'NG '}{label:<28} → {got}")
|
|
701
|
+
# 🔬 陽性対照: 表の判定が全部 None に潰れていない
|
|
702
|
+
good = {"STALLED-RATE-LIMIT", "STALLED-BACKEND", "STALLED-API-ERROR", None} <= seen
|
|
703
|
+
ok = ok and good
|
|
704
|
+
print(f" {'OK ' if good else 'NG '}陽性対照: 場面で判定が変わる → {sorted(str(x) for x in seen)}")
|
|
705
|
+
return ok
|
|
706
|
+
|
|
707
|
+
|
|
558
708
|
def verify():
|
|
559
709
|
"""対照実験: 測定器が既知ケースを正しく分類できるか。
|
|
560
710
|
|
|
@@ -565,6 +715,8 @@ def verify():
|
|
|
565
715
|
ghost_ok = verify_ghost()
|
|
566
716
|
print("\n--- API 応答不能の判定(純粋関数・実データ非依存)---")
|
|
567
717
|
stalled_ok = verify_stalled()
|
|
718
|
+
print("\n--- 足跡の判定と付け替え前後の一致(純粋関数・実データ非依存・箱 n=2475)---")
|
|
719
|
+
stalled_ok = verify_footprint() and stalled_ok
|
|
568
720
|
print(f"\n--- 死亡判定(既知ケース・実データ依存)---")
|
|
569
721
|
cases = {
|
|
570
722
|
# short_id: 期待する分類(人手で一次資料まで確認済み・2026-08-04)
|
|
@@ -607,7 +759,7 @@ def ghost_as_row(g):
|
|
|
607
759
|
if g.get("stalled"):
|
|
608
760
|
detail += f"/+ {g['stalled']}: API が応答しない(2 つの死角に同時該当)"
|
|
609
761
|
elif g.get("stalled_unverified"):
|
|
610
|
-
detail += "/+ transcript
|
|
762
|
+
detail += "/+ 足跡も transcript も読めず、応答可能かは未確認"
|
|
611
763
|
return {
|
|
612
764
|
"short_id": g["short_id"],
|
|
613
765
|
"spawned_at": g.get("spawned_at") or "(spawn 記録なし)",
|
|
@@ -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
|
+
}
|