@tyhld/conductor 0.12.105 → 0.12.107

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/relay.js CHANGED
@@ -1959,6 +1959,73 @@ export function countStandaloneDone(text, site) {
1959
1959
  n += 1;
1960
1960
  return n;
1961
1961
  }
1962
+ // ───────────────────────────────────────────────────────────────────────────
1963
+ // 終わりの印が【行の中】に描かれても拾う(★Codex だけ・便 0029ef4d)
1964
+ //
1965
+ // 【何が起きたか(実物 2026-10-05 夜・便 cd7d9ea8・配り役 0.12.101)】
1966
+ // Codex は 23:33 に返事を終え、入力待ち(`› Ask Codex to do anything`)に戻っていた。
1967
+ // ところが返事の本文が表(マークダウンの table)で終わっていたため、Codex の画面は
1968
+ // そのあとの2行まで【表の行として】描いた:
1969
+ // 型 =====原文ここまで===== そのまま使える物 …
1970
+ // 型 DONE:codex-probe
1971
+ // 既存の判定(isStandaloneDoneLine)は「印と装飾を除いて何も残らない行」だけを完了とみなすので、
1972
+ // 行の頭に残った `型` のせいで永久に成立しない。配り役の記録には 178 秒めから
1973
+ // 「画面=待機」と出ていたのに、完了は 15 分の上限まで拾えず、報告本文も載らなかった。
1974
+ //
1975
+ // 【直し方の筋】印の【描かれ方】だけに頼らない。「この行は印で終わっている」+「職人は入力待ちに
1976
+ // 戻っている」の2つで読む。印の【前】に何が付いていてもよく、印の【後ろ】に字が残っていたら拾わない。
1977
+ //
1978
+ // 【★指示文のこだま(画面に出る指示の写し)を終わりと取り違えない】守りは3重で、1つも緩めない:
1979
+ // ① 印の【後ろ】に字が残る行は拾わない … 配達文の「最終行: DONE:{現場} とだけ出力」は
1980
+ // 印の後ろに字が続くので、この1つ目で落ちる。
1981
+ // ② 入力欄に写った行(行頭が Codex のカーソル `›`)・アンカーを含む行は拾わない。
1982
+ // 貼った本文の写しは必ずこの形で描かれる(実測)。
1983
+ // ③ 床(配った時点の数)より増えたときだけ完了とみなす … 端末の折り返しがたまたま印の直後で
1984
+ // 切れて①②をすり抜けても、配った直後の周で床に取り込まれるので完了にならない。
1985
+ // ───────────────────────────────────────────────────────────────────────────
1986
+ /**
1987
+ * 印の【後ろ】に残っていても「字が無い」とみなす飾り。
1988
+ * 既存の単独行の判定(isStandaloneDoneLine)の飾りに、表・区切りの罫線と Codex のカーソルを足す。
1989
+ * ★既存の判定そのものは1文字も変えない(Claude の道を動かさないため)。
1990
+ */
1991
+ const TRAILING_DONE_DECOR_RE = /[│|>›⏺●○•*·─━┃┆┊╎—–\s]/g;
1992
+ /**
1993
+ * 1行が「`DONE:{site}` で終わっている行」か(★印の前に何が付いていてもよい)。
1994
+ * - 印より後ろに、飾りと空白以外が1字も残っていないこと。
1995
+ * - 行頭が Codex のカーソル(`›`)の行=入力欄に写った指示文は拾わない。
1996
+ * - アンカー(`CONDUCTOR_JOB`)を含む行=貼った本文の写しは拾わない。
1997
+ */
1998
+ export function isTrailingDoneLine(line, marker) {
1999
+ const at = line.lastIndexOf(marker);
2000
+ if (at < 0)
2001
+ return false;
2002
+ if (line.slice(at + marker.length).replace(TRAILING_DONE_DECOR_RE, '').length > 0)
2003
+ return false;
2004
+ const head = line.trim();
2005
+ if (head.startsWith(CODEX_MENU_CURSOR))
2006
+ return false;
2007
+ if (line.includes(SEND_ANCHOR_PREFIX))
2008
+ return false;
2009
+ return true;
2010
+ }
2011
+ /** テキスト中に「`DONE:{site}` で終わる行」が1つでもあるか。 */
2012
+ export function hasTrailingDone(text, site) {
2013
+ const marker = `DONE:${site}`;
2014
+ return text
2015
+ .replace(/\r/g, '')
2016
+ .split('\n')
2017
+ .some((line) => isTrailingDoneLine(line, marker));
2018
+ }
2019
+ /** テキスト中の「`DONE:{site}` で終わる行」の数(★床の基準に使う)。 */
2020
+ export function countTrailingDone(text, site) {
2021
+ const marker = `DONE:${site}`;
2022
+ const lines = text.replace(/\r/g, '').split('\n');
2023
+ let n = 0;
2024
+ for (const line of lines)
2025
+ if (isTrailingDoneLine(line, marker))
2026
+ n += 1;
2027
+ return n;
2028
+ }
1962
2029
  /**
1963
2030
  * 追跡中(pending)の便について、画面から完了の合図を読む(★I/Oなし=単体テスト可能)。
1964
2031
  *
@@ -1984,6 +2051,25 @@ export function scanDoneForPending(cap, site, anchor, opts) {
1984
2051
  return { done: true, floor: opts.doneFloor };
1985
2052
  return { done: false, floor: Math.min(opts.doneFloor, seen) };
1986
2053
  }
2054
+ /**
2055
+ * 「職人は入力待ちに戻っている」ときだけ、行の中に描かれた終わりの印を読む(★I/Oなし)。
2056
+ *
2057
+ * ★上の scanDoneForPending(今までの道)は1バイトも変えない。これは【それが空振りしたときだけ】
2058
+ * 足す2本目の道で、Codex の便で `idle:true` の周にしか効かない。
2059
+ * - `idle:false`(まだ手を動かしている・Claude の現場)… 何も読まない・床も動かさない。
2060
+ * - 床が在る … 配った時点の数より増えたときだけ完了。増えていなければ観測した数まで床を下げる。
2061
+ * - 床が無い(孤児の復元など)… 今までの単独行の判定と同じ危険度で、1本でも在れば完了。
2062
+ */
2063
+ export function scanTrailingDoneForPending(cap, site, opts) {
2064
+ if (!opts.idle)
2065
+ return { done: false, floor: opts.floor };
2066
+ if (opts.floor === null)
2067
+ return { done: hasTrailingDone(cap, site), floor: null };
2068
+ const seen = countTrailingDone(cap, site);
2069
+ if (seen > opts.floor)
2070
+ return { done: true, floor: opts.floor };
2071
+ return { done: false, floor: Math.min(opts.floor, seen) };
2072
+ }
1987
2073
  /**
1988
2074
  * アンカー基準の完了判定(根治の中核)。
1989
2075
  * 「アンカーより下の領域」に DONE:{site} 単独行があれば完了とみなす。
@@ -4289,6 +4375,71 @@ export async function sendHeartbeat(cfg, id, busy = false, liveness) {
4289
4375
  log(`site=${cfg.site} id=${id} heartbeat -> 失敗: ${err instanceof Error ? err.message : String(err)}(縮退で安全)`);
4290
4376
  }
4291
4377
  }
4378
+ // ───────────────────────────────────────────────────────────────────────────
4379
+ // 「職人は入力待ちに戻っているが、終わりの印が見つからない」を管制へ知らせる(便 0029ef4d)
4380
+ //
4381
+ // 【なぜ要るか(実物 2026-10-05 夜・便 cd7d9ea8)】配り役の記録には 178 秒めから
4382
+ // 「完了待ち(画面=待機)」と出ていた。=配り役は「職人の手は止まっている」と分かっていたのに、
4383
+ // その事実は手元の記録にしか残らず、管制には何も届かなかった。管制の画面では
4384
+ // 「返事待ち(15分)→ 確認しています(30分)→ 回収必要」と時間だけが進み、人が Codex の画面を
4385
+ // 見るまで誰も気づけなかった。
4386
+ //
4387
+ // 【どの口を使うか(★管制のリポは触らない)】既にある出来事の口
4388
+ // `POST /api/conductor/commands/{id}/events`(画面なし方式の職人が進捗を送っている口)へ、
4389
+ // 既にある種類 `progress` で1行送るだけ。★便の状態は1文字も書かない(完了にしない・
4390
+ // `lastSeenAt` も動かさない=管制の時間切れの働きを遅らせない)。
4391
+ // ───────────────────────────────────────────────────────────────────────────
4392
+ /** 「入力待ちなのに印が無い」を管制へ知らせるまでの待ち(★短すぎると普通の間(ま)で鳴る)。 */
4393
+ export const RELAY_IDLE_NO_DONE_NOTICE_MS = 5 * 60 * 1000;
4394
+ /**
4395
+ * この周で「入力待ちなのに終わりの印が無い」を管制へ知らせるかを決める純粋関数。
4396
+ *
4397
+ * ★1便に1回だけ(told)。★Codex の現場だけ(Claude は画面なし方式=終わりを実プロセスで掴める)。
4398
+ * ★印が見つかった周・まだ手を動かしている周では鳴らさない(idleNoDoneSince が null に戻る)。
4399
+ */
4400
+ export function decideIdleNoDoneNotice(args) {
4401
+ if (args.kind !== 'codex')
4402
+ return false;
4403
+ if (args.told)
4404
+ return false;
4405
+ if (!args.idle || args.doneSeen)
4406
+ return false;
4407
+ if (args.idleNoDoneSince === null)
4408
+ return false;
4409
+ return args.now - args.idleNoDoneSince >= RELAY_IDLE_NO_DONE_NOTICE_MS;
4410
+ }
4411
+ /** 管制へ知らせる1行の本文(★純粋関数・秘密は1文字も入れない)。 */
4412
+ export function idleNoDoneNoticeText(site, idleSeconds) {
4413
+ return (`職人(Codex)は入力待ちに戻っていますが、終わりの印(DONE:${site})が画面に見つかりません` +
4414
+ `(入力待ちになってから約${Math.max(0, Math.round(idleSeconds))}秒)。` +
4415
+ '返事を書き終えているのに印の書き方が違う/印が表の行として描かれた、のどちらかの可能性があります。');
4416
+ }
4417
+ /**
4418
+ * 管制の出来事の口へ1行送る(★最善努力。失敗は握りつぶしてログだけ)。
4419
+ * 便の状態は書かない・報告も送らない=この呼び出しで便が完了になることは無い。
4420
+ */
4421
+ export async function sendCommandNotice(cfg, commandId, text, opts = {}) {
4422
+ const doFetch = opts.fetchImpl ?? fetch;
4423
+ const logLine = opts.logLine ?? log;
4424
+ const at = new Date(opts.now?.() ?? Date.now()).toISOString();
4425
+ try {
4426
+ const res = await doFetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(commandId)}/events`, {
4427
+ method: 'POST',
4428
+ headers: { Authorization: `Bearer ${cfg.token}`, 'Content-Type': 'application/json' },
4429
+ body: JSON.stringify({ events: [{ type: 'progress', text, at, attempt: 1 }] }),
4430
+ signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
4431
+ });
4432
+ if (res.ok)
4433
+ return true;
4434
+ logLine(`site=${cfg.site} id=${commandId} 気づきの知らせ -> HTTP ${res.status}(送れなくても作業は止めません)`);
4435
+ return false;
4436
+ }
4437
+ catch (err) {
4438
+ logLine(`site=${cfg.site} id=${commandId} 気づきの知らせ -> 失敗: ` +
4439
+ `${err instanceof Error ? err.message : String(err)}(送れなくても作業は止めません)`);
4440
+ return false;
4441
+ }
4442
+ }
4292
4443
  /** 便の終わりの関門の種類(控えの数え方の見出し。★`finish_gate.py` の GATE_KINDS と同じ語)。 */
4293
4444
  export const FINISH_GATE_KINDS = ['report', 'pr', 'background'];
4294
4445
  /** 届けられなかった「終了の事実」の置き場。 */
@@ -4520,6 +4671,8 @@ export function startKeepalive(state, pending, now) {
4520
4671
  lastHeartbeatAt: null,
4521
4672
  lastCentralProbeAt: null,
4522
4673
  screenDoneUsable: true,
4674
+ // ★2本目の物差しの床を引き継ぐ(★引き継がないと、履歴に残る前の便の印を拾いかねない)。
4675
+ ...(pending.doneFloorLoose != null ? { doneFloorLoose: pending.doneFloorLoose } : {}),
4523
4676
  };
4524
4677
  return true;
4525
4678
  }
@@ -4637,12 +4790,26 @@ export async function keepaliveTick(cfg, state, deps = {}) {
4637
4790
  // 生存通知の可否(alive)は従来どおり busy ∪ メニュー=1文字も変えない。
4638
4791
  const kaBusySignal = cap !== null && hasBusySignalFor(cap, kindOf(cfg));
4639
4792
  const kaMenu = cap !== null && hasChoiceMenuFor(cap, kindOf(cfg));
4793
+ const kaIdle = cap !== null && looksIdleFor(cap, kindOf(cfg));
4794
+ // ★見張りでも、今までの道(単独行)が空振りしたら2本目の道を見る(便 0029ef4d)。
4795
+ // Codex の便で入力待ちの周だけ・引き継いだ床より増えたときだけ=守りは1つも緩めない。
4796
+ const kaTrailingScan = ka.screenDoneUsable && below !== null && kindOf(cfg) === 'codex'
4797
+ ? scanTrailingDoneForPending(below, ka.site, {
4798
+ idle: kaIdle,
4799
+ floor: ka.doneFloorLoose ?? null,
4800
+ })
4801
+ : null;
4802
+ // ★床は下がるだけ(古い印の行が履歴から流れたぶんだけ下げる=あとで出た合図を取りこぼさない)。
4803
+ if (kaTrailingScan !== null && ka.doneFloorLoose != null && kaTrailingScan.floor !== ka.doneFloorLoose) {
4804
+ ka.doneFloorLoose = kaTrailingScan.floor;
4805
+ }
4806
+ const kaTrailingDone = kaTrailingScan?.done === true;
4640
4807
  const action = decideKeepalive({
4641
4808
  expired,
4642
4809
  capOk: cap !== null,
4643
- doneSeen: ka.screenDoneUsable && below !== null && hasStandaloneDone(below, ka.site),
4810
+ doneSeen: (ka.screenDoneUsable && below !== null && hasStandaloneDone(below, ka.site)) || kaTrailingDone,
4644
4811
  alive: cap !== null && (kaBusySignal || kaMenu),
4645
- idle: cap !== null && looksIdleFor(cap, kindOf(cfg)),
4812
+ idle: kaIdle,
4646
4813
  throttleOk: ka.lastHeartbeatAt === null || now - ka.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS,
4647
4814
  // ★中央への答え合わせのしぼり。生存通知と同じ150秒(RELAY_HEARTBEAT_INTERVAL_MS)。
4648
4815
  centralProbeOk: ka.lastCentralProbeAt === null || now - ka.lastCentralProbeAt >= RELAY_HEARTBEAT_INTERVAL_MS,
@@ -4702,6 +4869,8 @@ export async function keepaliveTick(cfg, state, deps = {}) {
4702
4869
  if (report !== null)
4703
4870
  markReportProcessed(ka.id); // ★消さずに処理済みへ(二度送らない)
4704
4871
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
4872
+ // ★どちらの物差しで拾ったかを残す(便 0029ef4d・あとで原因を追えるように)。
4873
+ `${kaTrailingDone ? '(印は行の中に描かれていました)' : ''}` +
4705
4874
  `${report != null ? '(報告添付)' : kaDelivery.phase === 'none' ? '' : `(${kaDelivery.note})`}`);
4706
4875
  // ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
4707
4876
  noteBusyEdge(state, cfg.site, ka.id, false, logLine);
@@ -4979,6 +5148,8 @@ export async function relayTick(cfg, state, hooks) {
4979
5148
  if (state.pending.doneFloor != null && state.pending.doneFloorSettled !== true) {
4980
5149
  // 配達のエコーを含んだ数も床に取り込む(PendingEntry.doneFloorSettled の説明)。
4981
5150
  state.pending.doneFloor = Math.max(state.pending.doneFloor, countStandaloneDone(cap, cfg.site));
5151
+ // ★2本目の物差し(行の中に描かれた印)の床も、同じ周に決め終える(PendingEntry.doneFloorLoose)。
5152
+ state.pending.doneFloorLoose = Math.max(state.pending.doneFloorLoose ?? 0, countTrailingDone(cap, cfg.site));
4982
5153
  state.pending.doneFloorSettled = true;
4983
5154
  }
4984
5155
  const scan = scanDoneForPending(cap, cfg.site, state.pending.anchor, {
@@ -4988,7 +5159,48 @@ export async function relayTick(cfg, state, hooks) {
4988
5159
  if (state.pending.doneFloor != null && scan.floor !== state.pending.doneFloor) {
4989
5160
  state.pending.doneFloor = scan.floor;
4990
5161
  }
4991
- if (scan.done) {
5162
+ // ★今までの道が空振りしたときだけ、2本目の道を見る(便 0029ef4d)。
5163
+ // 「職人は入力待ちに戻っている」+「この行は印で終わっている」の2つで読むので、
5164
+ // 印が表の行として描かれても(行の頭に別の字が付いても)拾える。
5165
+ // ★Codex の便で入力待ちの周にしか効かない=Claude の現場は1バイトも変わらない。
5166
+ const idle = looksIdleFor(cap, kindOf(cfg));
5167
+ const looseScan = scanTrailingDoneForPending(cap, cfg.site, {
5168
+ idle: kindOf(cfg) === 'codex' && idle && !scan.done,
5169
+ floor: state.pending.doneFloorLoose ?? null,
5170
+ });
5171
+ if (state.pending.doneFloorLoose != null && looseScan.floor !== state.pending.doneFloorLoose) {
5172
+ state.pending.doneFloorLoose = looseScan.floor;
5173
+ }
5174
+ if (looseScan.done && !scan.done) {
5175
+ log(`site=${cfg.site} id=${state.pending.id} 終わりの印を【行の中】で見つけました` +
5176
+ '(職人は入力待ちに戻っています・印が表の行として描かれた形)');
5177
+ }
5178
+ // ── 「入力待ちなのに印が見つからない」を管制へ1回だけ知らせる(便 0029ef4d) ──
5179
+ // ★便の状態は書かない(完了にしない)。知らせるだけ。
5180
+ const doneSeenNow = scan.done || looseScan.done;
5181
+ if (!idle || doneSeenNow) {
5182
+ state.pending.idleNoDoneSince = null;
5183
+ }
5184
+ else if (state.pending.idleNoDoneSince == null) {
5185
+ state.pending.idleNoDoneSince = Date.now();
5186
+ }
5187
+ if (decideIdleNoDoneNotice({
5188
+ kind: kindOf(cfg),
5189
+ idle,
5190
+ doneSeen: doneSeenNow,
5191
+ idleNoDoneSince: state.pending.idleNoDoneSince ?? null,
5192
+ now: Date.now(),
5193
+ told: state.pending.idleNoDoneTold === true,
5194
+ })) {
5195
+ state.pending.idleNoDoneTold = true;
5196
+ const idleSec = (Date.now() - (state.pending.idleNoDoneSince ?? Date.now())) / 1000;
5197
+ const text = idleNoDoneNoticeText(cfg.site, idleSec);
5198
+ log(`WARN site=${cfg.site} id=${state.pending.id} ${text}`);
5199
+ await sendCommandNotice(cfg, state.pending.id, text, { logLine: log });
5200
+ if (state.pending === null)
5201
+ return; // 念のため(送信中に追跡が畳まれた場合)
5202
+ }
5203
+ if (doneSeenNow) {
4992
5204
  // ★報告は画面から読まない。職人が書いたファイルを読む(relayReportDirs / readReport)。
4993
5205
  // 画面を読む方式は窓が 16〜34 行しかなく、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
4994
5206
  // ★「届いた」は形まで見る(便 461be339)。整っていなければ理由も一緒に届ける。
@@ -5035,7 +5247,7 @@ export async function relayTick(cfg, state, hooks) {
5035
5247
  // ・それ以外の周 … 150秒(RELAY_HEARTBEAT_INTERVAL_MS・生存通知と同じ時計)に1回
5036
5248
  // =HTTP の増分は1指示あたり最大で150秒に1回。画面の読み取りが外れても、中央で終わった便は
5037
5249
  // 必ず150秒以内の周で追跡解除され、次の便が配られる。
5038
- const idle = looksIdleFor(cap, kindOf(cfg));
5250
+ // ★画面の待機の読み(idle)は、上の完了検出の手前で1回だけ測って使い回す(値は同じ・純粋関数)。
5039
5251
  const probeBase = state.pending.lastStatusProbeAt ?? state.pending.sentAt;
5040
5252
  const probeDue = Date.now() - probeBase >= RELAY_HEARTBEAT_INTERVAL_MS;
5041
5253
  const askedCentral = state.pending.sentMarked && (idle || probeDue);
@@ -5396,7 +5608,14 @@ export async function relayTick(cfg, state, hooks) {
5396
5608
  delivered: true,
5397
5609
  sentMarked: false,
5398
5610
  screenDoneWide: codexFloor !== null,
5399
- ...(codexFloor !== null ? { doneFloor: codexFloor, doneFloorSettled: false } : {}),
5611
+ ...(codexFloor !== null
5612
+ ? {
5613
+ doneFloor: codexFloor,
5614
+ // ★2本目の物差し(行の中に描かれた印)の床も、同じ画面から数えて覚える(便 0029ef4d)。
5615
+ doneFloorLoose: countTrailingDone(guardCap, cfg.site),
5616
+ doneFloorSettled: false,
5617
+ }
5618
+ : {}),
5400
5619
  ...(useAck ? { markAs: 'delivering' } : {}),
5401
5620
  };
5402
5621
  // 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.105",
3
+ "version": "0.12.107",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -618,7 +618,9 @@ def verb_of(tokens):
618
618
  i = 0
619
619
  while i < len(tokens):
620
620
  t = tokens[i]
621
- if _ASSIGN_RE.match(t):
621
+ # ★文の頭の予約語(do / then / until …)も剥がす(便 afb46a15・_tool_words と同じ根)。
622
+ # 剥がさないと `while true; do cat .env; done` の動詞が do になり、秘密の読み出しが素通りした。
623
+ if _ASSIGN_RE.match(t) or t in _SHELL_KEYWORDS:
622
624
  i += 1
623
625
  continue
624
626
  base = t.split('/')[-1]
@@ -1392,7 +1394,11 @@ def _tool_words(tokens):
1392
1394
  i = 0
1393
1395
  while i < len(tokens):
1394
1396
  t = tokens[i]
1395
- if _ASSIGN_RE.match(t):
1397
+ # ★文の頭の予約語(until / while / do / if / then / ! …)も剥がす(便 afb46a15)。
1398
+ # 剥がさないと `until gh run list …` の道具が until と読まれ、文字を扱うだけの gh が
1399
+ # SQL の字面検査へ落ちて人に聞いていた。逆に `until psql -h 本番 …` や
1400
+ # `if true; then supabase db push; fi` は道具が見えず素通りしていた(両方この1か所が根)。
1401
+ if _ASSIGN_RE.match(t) or t in _SHELL_KEYWORDS:
1396
1402
  i += 1
1397
1403
  continue
1398
1404
  base = t.split('/')[-1]
@@ -1881,7 +1887,7 @@ _PG_ENV_INDIRECT = (
1881
1887
  _TUNNEL_TOOLS = frozenset({
1882
1888
  'ssh', 'autossh', 'sshuttle', 'socat', 'ncat', 'nc', 'netcat', 'chisel',
1883
1889
  'ngrok', 'cloudflared', 'stunnel', 'pgbouncer', 'kubectl', 'oc',
1884
- 'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc', 'iptables', 'nft',
1890
+ 'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc', 'iptables', 'ip6tables', 'nft',
1885
1891
  })
1886
1892
 
1887
1893
 
@@ -2023,6 +2029,248 @@ def _tunneled_db_danger(command: str) -> str:
2023
2029
  return None
2024
2030
 
2025
2031
 
2032
+ # ─────────────────────────────────────────────────────────────────────────────
2033
+ # ★ポート転送を【単独のコマンドでも】止める(便 afb46a15・便 3747bf25 の報告の根治)
2034
+ #
2035
+ # 【なぜ要るか(実測 2026-10-05・番人 0.12.101)】上の _tunneled_db_danger は「転送と SQL クライアントが
2036
+ # 同じコマンドに在る」形しか見ていなかった。`ssh -N -L 55432:<本番>:5432 …` を先に張り、別の
2037
+ # コマンドで `psql -h 127.0.0.1 -p 55432` を打つと、どちらも単独では allow で、番人の層だけでは
2038
+ # 本番へ届く道が残っていた(6通りのうち5番目だけが allow)。
2039
+ # 【どう止めるか】転送の行き先を読み、次のどれかなら deny。
2040
+ # ・行き先が手元の数字の宛先(127.0.0.1・::1)でない(★名前は許さない=psql の決まりと同じ)
2041
+ # ・行き先のポートが DB の番号(5432・6543)
2042
+ # ・行き先が読めない(変数・コマンド置換・設定ファイル -F・動的転送 -D・道具の設定に隠れる形)
2043
+ # 【変えないこと】転送の無い ssh(`ssh -T git@github.com`・git の ssh)・手元の使い捨てへの psql。
2044
+ # ─────────────────────────────────────────────────────────────────────────────
2045
+
2046
+ #: 転送の行き先として止めるポート(Postgres の既定・Supabase の pooler)。
2047
+ _DB_FORWARD_PORTS = frozenset({'5432', '6543'})
2048
+
2049
+ #: ssh の、値を1つ取る短い旗(`-NL 55432:…` のようなまとめ書きを読むため)。
2050
+ _SSH_ARG_FLAGS = frozenset('BbcDEeFIiJLlmOopQRSWw')
2051
+
2052
+ #: 転送そのものが役目で、行き先が設定・外のサービスに隠れる道具(版・使い方の表示だけは通す)。
2053
+ _TUNNEL_ONLY_TOOLS = frozenset({
2054
+ 'sshuttle', 'chisel', 'ngrok', 'cloudflared', 'stunnel', 'pgbouncer',
2055
+ 'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc',
2056
+ })
2057
+ _INFO_ONLY_ARGS = frozenset({'--version', '-v', '-V', 'version', '--help', '-h', 'help'})
2058
+
2059
+ #: socat の、つなぎに行く側の宛先の種類。
2060
+ _SOCAT_CONNECT_TYPES = frozenset({
2061
+ 'tcp', 'tcp4', 'tcp6', 'tcp-connect', 'tcp4-connect', 'tcp6-connect', 'openssl',
2062
+ 'openssl-connect', 'ssl', 'proxy', 'proxy-connect', 'socks', 'socks4', 'socks4a', 'socks5',
2063
+ 'udp', 'udp4', 'udp6', 'udp-connect', 'udp4-connect', 'udp6-connect', 'sctp', 'sctp-connect',
2064
+ })
2065
+ #: socat の、何でも実行できる宛先(行き先が読めない=止める)。
2066
+ _SOCAT_EXEC_TYPES = frozenset({'exec', 'system', 'shell'})
2067
+
2068
+
2069
+ def _split_host_parts(spec: str):
2070
+ """`a:[::1]:5432` を ['a', '[::1]', '5432'] に割る(角括弧の中の : では割らない)。"""
2071
+ parts, cur, depth = [], [], 0
2072
+ for c in spec:
2073
+ if c == '[':
2074
+ depth += 1
2075
+ elif c == ']':
2076
+ depth -= 1
2077
+ if c == ':' and depth == 0:
2078
+ parts.append(''.join(cur))
2079
+ cur = []
2080
+ continue
2081
+ cur.append(c)
2082
+ parts.append(''.join(cur))
2083
+ return parts
2084
+
2085
+
2086
+ def _forward_dest_risk(host: str, port: str, what: str):
2087
+ """転送の行き先(ホスト・ポート)を測る。止めるなら理由、通すなら None。"""
2088
+ host = _strip_quotes(host or '')
2089
+ port = _strip_quotes(port or '')
2090
+ if not host or not port or '$' in host + port or '`' in host + port:
2091
+ return f'{what}: 行き先が読めない'
2092
+ if port in _DB_FORWARD_PORTS:
2093
+ return f'{what}: 行き先が DB のポート ({port})'
2094
+ if host not in _LOCAL_HOST_LITERALS:
2095
+ return f'{what}: 行き先が手元でない ({host})'
2096
+ if not port.isdigit():
2097
+ return f'{what}: 行き先のポートが読めない ({port})'
2098
+ return None
2099
+
2100
+
2101
+ def _ssh_forward_spec_risk(flag: str, spec: str):
2102
+ """-L / -R / -W の値を測る。"""
2103
+ spec = _strip_quotes(spec or '')
2104
+ if not spec or '$' in spec or '`' in spec:
2105
+ return f'ssh {flag}: 行き先が読めない'
2106
+ if flag == '-W':
2107
+ parts = _split_host_parts(spec)
2108
+ if len(parts) != 2:
2109
+ return f'ssh {flag}: 行き先が読めない'
2110
+ return _forward_dest_risk(parts[0], parts[1], f'ssh {flag}')
2111
+ parts = _split_host_parts(spec)
2112
+ if len(parts) >= 3:
2113
+ return _forward_dest_risk(parts[-2], parts[-1], f'ssh {flag}')
2114
+ # `-L 55432:/var/run/…sock`(向こうのソケットへ)・`-R 1080`(向こうでの動的転送)は行き先が読めない。
2115
+ return f'ssh {flag}: 行き先が読めない'
2116
+
2117
+
2118
+ def _ssh_option_risk(opt: str):
2119
+ """-o の値(`LocalForward=…`・`LocalForward 55432 db:5432`)を測る。"""
2120
+ opt = _strip_quotes(opt or '')
2121
+ if '$' in opt or '`' in opt:
2122
+ return 'ssh -o: 設定が読めない'
2123
+ m = re.match(r'\s*([A-Za-z]+)\s*(?:=\s*|\s+)(.*)$', opt)
2124
+ if not m:
2125
+ return None
2126
+ key, value = m.group(1).lower(), m.group(2).strip()
2127
+ if key == 'dynamicforward':
2128
+ return 'ssh -o DynamicForward: 行き先が読めない'
2129
+ if key in ('localforward', 'remoteforward'):
2130
+ flag = '-L' if key == 'localforward' else '-R'
2131
+ fields = value.split()
2132
+ if len(fields) == 2:
2133
+ return _ssh_forward_spec_risk(flag, f'{fields[0]}:{fields[1]}')
2134
+ if len(fields) == 1: # `LocalForward=55432:db:5432` のように : でつないだ形
2135
+ return _ssh_forward_spec_risk(flag, fields[0])
2136
+ return f'ssh -o {m.group(1)}: 行き先が読めない'
2137
+ if key == 'include':
2138
+ return 'ssh -o Include: 設定が読めない'
2139
+ return None
2140
+
2141
+
2142
+ def _ssh_forward_risk(args):
2143
+ """ssh / autossh の引数(道具の名前より後ろ)から転送を探して測る。"""
2144
+ i, host_seen = 0, False
2145
+ while i < len(args):
2146
+ a = args[i]
2147
+ if a == '--':
2148
+ break
2149
+ if not a.startswith('-') or a == '-':
2150
+ # ★ssh は宛先のホストの【すぐ後ろ】の旗も読む(`ssh host -L …` も転送になる)。
2151
+ # ホストのあとに旗でない語が来たら、そこからは向こうで動くコマンド。
2152
+ if host_seen:
2153
+ break
2154
+ host_seen = True
2155
+ i += 1
2156
+ continue
2157
+ if a.startswith('--'):
2158
+ i += 1
2159
+ continue
2160
+ j = 1
2161
+ while j < len(a):
2162
+ c = a[j]
2163
+ if c in _SSH_ARG_FLAGS:
2164
+ value = a[j + 1:]
2165
+ if not value:
2166
+ i += 1
2167
+ value = args[i] if i < len(args) else ''
2168
+ flag = '-' + c
2169
+ if c in 'LRW':
2170
+ hit = _ssh_forward_spec_risk(flag, value)
2171
+ if hit:
2172
+ return hit
2173
+ elif c == 'D':
2174
+ return 'ssh -D: 動的転送は行き先が読めない'
2175
+ elif c == 'o':
2176
+ hit = _ssh_option_risk(value)
2177
+ if hit:
2178
+ return hit
2179
+ elif c == 'F' and _strip_quotes(value) != '/dev/null':
2180
+ return 'ssh -F: 設定ファイルに転送が隠れうる(行き先が読めない)'
2181
+ break
2182
+ j += 1
2183
+ i += 1
2184
+ return None
2185
+
2186
+
2187
+ def _socat_risk(args):
2188
+ for a in args:
2189
+ if a.startswith('-'):
2190
+ continue
2191
+ a = _strip_quotes(a)
2192
+ if '$' in a or '`' in a:
2193
+ return 'socat: 行き先が読めない'
2194
+ kind, _, rest = a.partition(':')
2195
+ kind = kind.lower()
2196
+ rest = rest.split(',', 1)[0]
2197
+ if kind in _SOCAT_EXEC_TYPES:
2198
+ return f'socat {kind}: 行き先が読めない'
2199
+ if kind in _SOCAT_CONNECT_TYPES:
2200
+ parts = _split_host_parts(rest)
2201
+ if len(parts) < 2:
2202
+ return f'socat {kind}: 行き先が読めない'
2203
+ hit = _forward_dest_risk(parts[0], parts[1], f'socat {kind}')
2204
+ if hit:
2205
+ return hit
2206
+ elif kind.endswith('-listen') or kind.endswith('-l'):
2207
+ if rest in _DB_FORWARD_PORTS:
2208
+ return f'socat {kind}: DB のポートで待ち受ける ({rest})'
2209
+ return None
2210
+
2211
+
2212
+ def _nc_risk(args):
2213
+ """nc の仲間。★待ち受け(中継に使える形)と DB のポートだけを止める(`nc -z host 443` は変えない)。"""
2214
+ for a in args:
2215
+ low = _strip_quotes(a)
2216
+ if low in ('--listen', '--sh-exec', '--exec', '--lua-exec') \
2217
+ or (low.startswith('-') and not low.startswith('--')
2218
+ and any(c in low[1:] for c in 'lec')):
2219
+ return 'nc: 待ち受け・中継の形'
2220
+ if low in _DB_FORWARD_PORTS or any(low.endswith(':' + p) for p in _DB_FORWARD_PORTS):
2221
+ return f'nc: 行き先が DB のポート ({low})'
2222
+ return None
2223
+
2224
+
2225
+ def _forward_segment_risk(tokens):
2226
+ i, _seen = _unwrap(tokens)
2227
+ if i >= len(tokens):
2228
+ return None
2229
+ tool = tokens[i].split('/')[-1].lower()
2230
+ args = tokens[i + 1:]
2231
+ # ★道具の一覧の正本は _TUNNEL_TOOLS の1か所(同じコマンドに並ぶ形の判定と共用・2つに割らない)。
2232
+ if tool not in _TUNNEL_TOOLS:
2233
+ return None
2234
+ if tool in ('ssh', 'autossh'):
2235
+ if tool == 'autossh': # autossh 自身の -M <見張りのポート> を外してから ssh として読む
2236
+ args = [a for k, a in enumerate(args)
2237
+ if a != '-M' and not (k > 0 and args[k - 1] == '-M')]
2238
+ hit = _ssh_forward_risk(args)
2239
+ return f'{tool} の転送 — {hit}' if hit else None
2240
+ if tool in ('kubectl', 'oc') and 'port-forward' in [a.lower() for a in args]:
2241
+ return f'{tool} port-forward(行き先が手元でない)'
2242
+ if tool in _TUNNEL_ONLY_TOOLS:
2243
+ if args and all(a in _INFO_ONLY_ARGS for a in args):
2244
+ return None
2245
+ return f'{tool}(転送の道具・行き先が読めない)'
2246
+ if tool == 'socat':
2247
+ return _socat_risk(args)
2248
+ if tool in ('nc', 'ncat', 'netcat'):
2249
+ return _nc_risk(args)
2250
+ if tool in ('iptables', 'ip6tables', 'nft'):
2251
+ low = ' '.join(args).lower()
2252
+ if any(w in low for w in ('dnat', 'redirect', '--to-destination', '--to-ports', 'masquerade')):
2253
+ return f'{tool}(通信の向け替え)'
2254
+ return None
2255
+
2256
+
2257
+ def _port_forward_danger(command: str, depth: int = 0):
2258
+ """コマンドの中のポート転送を、単独でも測る。止めるなら理由。"""
2259
+ for seg in split_commands(command, bodies=False):
2260
+ hit = _forward_segment_risk(tokens_of(seg))
2261
+ if hit:
2262
+ return f'本番へ届きうるポート転送 ({hit})'
2263
+ if depth < _UNWRAP_MAX_DEPTH:
2264
+ inners = [inner for kind, _r2, inner in _inner_payloads(command) if kind == 'shell']
2265
+ inners += _subst_payloads(command)
2266
+ for inner in inners:
2267
+ if inner and inner.strip():
2268
+ hit = _port_forward_danger(inner, depth + 1)
2269
+ if hit:
2270
+ return hit
2271
+ return None
2272
+
2273
+
2026
2274
  def _prod_db_change(seg, tokens):
2027
2275
  """1セグメントが本番 DB を直接変える道具なら理由を返す。"""
2028
2276
  words = _tool_words(tokens)
@@ -2053,6 +2301,12 @@ def _prod_db_change_scan(command: str, depth: int = 0):
2053
2301
  hit = _prod_db_change_scan(inner, depth + 1)
2054
2302
  if hit:
2055
2303
  return f'{hit} (inside {runner})'
2304
+ # ★コマンド置換の中身(`[ -n "$(psql …)" ]` の形・便 afb46a15)も同じ物差しで測る。
2305
+ for inner in _subst_payloads(command):
2306
+ if inner and inner.strip():
2307
+ hit = _prod_db_change_scan(inner, depth + 1)
2308
+ if hit:
2309
+ return f'{hit} (inside $())'
2056
2310
  return None
2057
2311
 
2058
2312
 
@@ -3544,15 +3798,27 @@ def _shell_payloads(head_tokens, body):
3544
3798
  return out
3545
3799
 
3546
3800
 
3801
+ #: 繰り返し・条件の頭に立つが、自分では何も実行しない語(`[ … ]`・`for x in …`・`sleep 15`)。
3802
+ # ★中の `$(…)` は _gate_one が別のコマンドとして測るので、ここを通しても中身は見逃さない。
3803
+ _NO_EXEC_HEADS = frozenset({'[', '[[', 'for', 'case', 'select', 'sleep', ':', 'break', 'continue'})
3804
+
3805
+
3547
3806
  def _gate_one(segment: str, depth: int):
3548
3807
  """本物の1コマンドの関門。返り (種別, 理由) / None。"""
3549
3808
  head, _, body = segment.partition('\n')
3550
3809
  toks = tokens_of(head)
3810
+ # ★コマンド置換・プロセス置換の中身(`$(…)`・`` `…` ``・`<(…)`)は、それ自体が1つのコマンド。
3811
+ # 同じ物差しで測る(便 afb46a15)。外側が文字を扱うだけの道具でも、中身は実行されるため。
3812
+ if depth < _UNWRAP_MAX_DEPTH:
3813
+ for inner in _subst_payloads(head):
3814
+ hit = _gate_command(inner, depth + 1)
3815
+ if hit:
3816
+ return hit
3551
3817
  words = _tool_words(toks)
3552
3818
  if not words:
3553
3819
  return None
3554
3820
  v = words[0]
3555
- if v in _SQL_TEXT_ONLY_VERBS:
3821
+ if v in _SQL_TEXT_ONLY_VERBS or v in _NO_EXEC_HEADS:
3556
3822
  return None # 文字を扱うだけ。引数・本文に何が書かれていても実行されない。
3557
3823
  if v in _SHELL_RUNNERS:
3558
3824
  if depth >= _UNWRAP_MAX_DEPTH:
@@ -3932,6 +4198,10 @@ def decide_bash(command: str, cwd=None):
3932
4198
 
3933
4199
  # ---- 1d') 手元に見せかけた回り道での DB 実行(トンネル・ポート転送・便 ee34f7e5) ----
3934
4200
  hit = _tunneled_db_danger(command)
4201
+ if hit:
4202
+ return _r(DENY, f'danger: {hit}')
4203
+ # ---- 1d'') ポート転送を単独でも(便 afb46a15・張ってから別のコマンドで psql を打つ形) ----
4204
+ hit = _port_forward_danger(command)
3935
4205
  if hit:
3936
4206
  return _r(DENY, f'danger: {hit}')
3937
4207
 
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """ポート転送を単独でも止める決まりと、読むだけの待ちの繰り返しを人に聞かない決まりの検査(便 afb46a15)。
3
+
4
+ 実行: python3 scripts/hooks/test_port_forward_and_wait_loop.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【何を守っているか】
8
+ (t) 便 3747bf25 と同じ6通り … 本番への5通りが deny・手元の使い捨てへの1通りが allow。
9
+ ★訊くだけ。実際には1回もつながない(宛先は形だけの文字)。
10
+ (f) ★negative test =転送の行き先が手元以外/ポートが 5432・6543/行き先が読めない形は、
11
+ 単独のコマンドでも止まる(ssh -L・-R・-D・-W・-o・-F・autossh・その他の転送の道具)。
12
+ (k) 転送の無い ssh・git の ssh・手元どうしの転送・手元の使い捨てへの psql は今までどおり通る。
13
+ (w) 読むだけの待ちの繰り返し(gh run list / view・gh pr checks / view + sleep)は人に聞かずに通る。
14
+ ★名前・題名に grant / revoke / create table の字が入っていても通る(便 6db73d94 の形)。
15
+ (m) ★negative test =繰り返し・条件の中に、本番反映・DB の変更・保護の迂回・秘密の読み出し・
16
+ 本番ブランチへの push が1つでも混ざれば、今までどおり止まる/人に聞く。
17
+ (n) 2026-09-21 の形(node でリポの中の読むだけの照合を呼ぶ)は通る。
18
+ (v) 空振り確認 … 直した所を外すと、上の期待が実際に崩れること(試験が飾りでないこと)。
19
+
20
+ ★この台本は道具の名前を素の字で置かない(組み立てて使う)=test_local_psql と同じ作法。
21
+ """
22
+ import os
23
+ import sys
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+
27
+ import policy # noqa: E402
28
+
29
+ T = 'p' + 'sql'
30
+ CENTRAL = 'supa' + 'base'
31
+ HOSTING = 'ver' + 'cel'
32
+ PROD = 'db.example-ref.supa' + 'base.co'
33
+ POOLER = 'aws-0-ap-northeast-1.pooler.supa' + 'base.com'
34
+ JUMP = 'example-user@example.invalid'
35
+ SEL = 'select 1'
36
+ W_GRANT = 'gr' + 'ant'
37
+ W_REVOKE = 're' + 'voke'
38
+ W_DDL = 'cre' + 'ate table'
39
+
40
+ CWD = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
41
+ failures = []
42
+
43
+
44
+ def check(label, got, want):
45
+ ok = got == want
46
+ if not ok:
47
+ failures.append(f'{label}: got={got!r} want={want!r}')
48
+ print(f'{"OK " if ok else "NG "} {label:72} {got}')
49
+
50
+
51
+ def decide(cmd):
52
+ return policy.decide_bash(cmd, CWD)['decision']
53
+
54
+
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ # (t) 便 3747bf25 と同じ6通り(5番目がこの便で allow → deny に変わった)
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+ SIX = [
59
+ ('t1 本番のホストへ直に', f'{T} -h {PROD} -p 5432 -c "{SEL}"', policy.DENY),
60
+ ('t2 手元の共有 DB(5432)', f'{T} -h 127.0.0.1 -p 5432 -c "{SEL}"', policy.DENY),
61
+ ('t3 pooler の接続文字列', f"{T} 'postgresql://postgres.x:p@{POOLER}:6543/postgres' -c \"{SEL}\"",
62
+ policy.DENY),
63
+ ('t4 名前の localhost(向け替えうる)', f'{T} -h localhost -p 55432 -c "{SEL}"', policy.DENY),
64
+ ('t5 ★トンネルだけを張る(直す前は allow)', f'ssh -N -L 55432:{PROD}:5432 {JUMP}', policy.DENY),
65
+ ('t6 手元の使い捨てへ', f'{T} -h 127.0.0.1 -p 55432 -c "{SEL}"', policy.ALLOW),
66
+ ]
67
+ for label, cmd, want in SIX:
68
+ check(label, decide(cmd), want)
69
+
70
+ # ─────────────────────────────────────────────────────────────────────────────
71
+ # (f) negative test =転送を単独でも止める
72
+ # ─────────────────────────────────────────────────────────────────────────────
73
+ FORWARD_DENY = [
74
+ ('f1 -L 行き先が本番', f'ssh -L 55432:{PROD}:5432 {JUMP}'),
75
+ ('f2 -L 行き先が向こうの手元の 5432', f'ssh -L 55432:127.0.0.1:5432 {JUMP}'),
76
+ ('f3 -L 行き先が手元以外(DB でないポートでも)', f'ssh -L 55432:10.0.0.5:8080 {JUMP}'),
77
+ ('f4 -L 行き先が名前の localhost', f'ssh -L 55432:localhost:8080 {JUMP}'),
78
+ ('f5 -NL のまとめ書き', f'ssh -NL 55432:{PROD}:5432 {JUMP}'),
79
+ ('f6 -L55432:… の付け書き', f'ssh -N -L55432:{PROD}:5432 {JUMP}'),
80
+ ('f7 ホストの後ろに -L', f'ssh {JUMP} -N -L 55432:{PROD}:5432'),
81
+ ('f8 -R 行き先が 6543', f'ssh -R 55432:{PROD}:6543 {JUMP}'),
82
+ ('f9 -R 動的(ポートだけ)', f'ssh -R 1080 {JUMP}'),
83
+ ('f10 -D 動的転送', f'ssh -D 1080 {JUMP}'),
84
+ ('f11 -W 標準入出力の転送', f'ssh -W {PROD}:5432 {JUMP}'),
85
+ ('f12 -o LocalForward(空白)', f"ssh -o 'LocalForward 55432 {PROD}:5432' {JUMP}"),
86
+ ('f13 -o LocalForward=(: つなぎ)', f'ssh -oLocalForward=55432:{PROD}:5432 {JUMP}'),
87
+ ('f14 -o RemoteForward', f"ssh -o 'RemoteForward=55432 {PROD}:6543' {JUMP}"),
88
+ ('f15 -o DynamicForward', f'ssh -o DynamicForward=1080 {JUMP}'),
89
+ ('f16 -F 設定ファイル(転送が隠れうる)', 'ssh -F ./tunnel.conf bastion'),
90
+ ('f17 -L の行き先が変数', f'ssh -L "$FWD" {JUMP}'),
91
+ ('f18 -L の行き先がコマンド置換', f'ssh -L "55432:$(cat host.txt):5432" {JUMP}'),
92
+ ('f19 autossh', f'autossh -M 0 -N -L 55432:{PROD}:5432 {JUMP}'),
93
+ ('f20 nohup で包む・背景で張る', f'nohup ssh -fN -L 55432:{PROD}:5432 {JUMP} &'),
94
+ ('f21 bash -c の中', f"bash -c 'ssh -N -L 55432:{PROD}:5432 {JUMP}'"),
95
+ ('f22 繰り返しの中', f'until ssh -N -L 55432:{PROD}:5432 {JUMP}; do sleep 5; done'),
96
+ ('f23 kubectl port-forward', 'kubectl port-forward svc/postgres 55432:5432'),
97
+ ('f24 socat で本番へ中継', f'socat TCP-LISTEN:55432,fork TCP:{PROD}:5432'),
98
+ ('f25 socat で何でも実行', 'socat TCP-LISTEN:55432,fork EXEC:./relay.sh'),
99
+ ('f26 sshuttle', f'sshuttle -r {JUMP} 10.0.0.0/8'),
100
+ ('f27 cloud-sql-proxy', 'cloud-sql-proxy --port 55432 proj:region:inst'),
101
+ ('f28 nc で待ち受けて中継', f'nc -l 55432 | nc {PROD} 5432'),
102
+ ('f29 iptables で向け替え', 'iptables -t nat -A OUTPUT -p tcp --dport 55432 -j DNAT --to-destination 10.0.0.5:5432'),
103
+ ]
104
+ for label, cmd in FORWARD_DENY:
105
+ check(label, decide(cmd), policy.DENY)
106
+
107
+ # ─────────────────────────────────────────────────────────────────────────────
108
+ # (k) 今までどおり通す
109
+ # ─────────────────────────────────────────────────────────────────────────────
110
+ KEEP = [
111
+ ('k1 git の ssh の確かめ', 'ssh -T git@github.com'),
112
+ ('k2 転送の無い ssh', f'ssh {JUMP} uptime'),
113
+ ('k3 向こうで動くコマンドの -L は転送でない', f'ssh -p 2222 {JUMP} ls -L /srv'),
114
+ ('k4 手元どうしの転送(使い捨ての帯)', f'ssh -N -L 55432:127.0.0.1:55433 {JUMP}'),
115
+ ('k5 git fetch', 'git fetch origin'),
116
+ ('k6 kubectl の読むだけ', 'kubectl get pods'),
117
+ ('k7 nc で手元の口を確かめる', 'nc -z 127.0.0.1 55432'),
118
+ ('k8 nc で外の口を確かめる', 'nc -z example.invalid 443'),
119
+ ('k9 転送の道具の版を見るだけ', 'cloudflared --version'),
120
+ ('k10 手元の使い捨てへの psql', f'{T} -h 127.0.0.1 -p 55432 -c "{SEL}"'),
121
+ ('k11 ssh -F /dev/null(設定を読まない)', f'ssh -F /dev/null {JUMP} uptime'),
122
+ ]
123
+ for label, cmd in KEEP:
124
+ check(label, decide(cmd), policy.ALLOW)
125
+
126
+ # ─────────────────────────────────────────────────────────────────────────────
127
+ # (w) 読むだけの待ちの繰り返しは人に聞かない
128
+ # ─────────────────────────────────────────────────────────────────────────────
129
+ BR = f'fix/{W_GRANT}-anon-select'
130
+ WAIT_ALLOW = [
131
+ ('w1 until gh run list | grep', 'until gh run list --branch fix/x --limit 1 --json status | grep -q in_progress; do sleep 15; done'),
132
+ ('w2 until [ -n "$(gh run list …)" ]',
133
+ "until [ -n \"$(gh run list --branch fix/x --limit 1 --json databaseId -q '.[0].databaseId')\" ]; do sleep 15; done"),
134
+ ('w3 for i in $(seq) で gh run list', 'for i in $(seq 1 40); do gh run list --limit 1; sleep 15; done'),
135
+ ('w4 while true で gh pr checks', 'while true; do gh pr checks 12 && break; sleep 15; done'),
136
+ ('w5 until gh run view', 'until gh run view 123 --json status -q .status | grep -q completed; do sleep 15; done'),
137
+ ('w6 ★ブランチ名に grant($() の中)',
138
+ f"until [ -n \"$(gh run list --branch {BR} --limit 1 --json databaseId -q '.[0].databaseId')\" ]; do sleep 15; done"),
139
+ ('w7 ★jq の中に grant の名前',
140
+ f"until gh run list --limit 1 --json status -q '.[] | select(.headBranch==\"{BR}\") | .status' | grep -q .; do sleep 15; done"),
141
+ ('w8 ★題名に revoke', f"while true; do gh pr view 12 --json title -q '.title | test(\"{W_REVOKE}\")' && break; sleep 15; done"),
142
+ ('w9 ★ワークフロー名に create table', f"until gh run list --workflow '{W_DDL} check' --limit 1; do sleep 15; done"),
143
+ ('w10 if で gh pr view', f"if gh pr view 12 --json title -q .title | grep -q {W_GRANT}; then echo y; fi"),
144
+ ('w11 until ! で gh run view', 'until ! gh run view 123 --json status -q .status | grep -q in_progress; do sleep 15; done'),
145
+ ]
146
+ for label, cmd in WAIT_ALLOW:
147
+ check(label, decide(cmd), policy.ALLOW)
148
+
149
+ # ─────────────────────────────────────────────────────────────────────────────
150
+ # (m) negative test =混ざった繰り返し・条件は今までどおり止まる/人に聞く
151
+ # ─────────────────────────────────────────────────────────────────────────────
152
+ MIXED = [
153
+ ('m1 繰り返しの中で本番 DB', f'until {T} -h {PROD} -p 5432 -c "{SEL}"; do sleep 5; done', policy.DENY),
154
+ ('m2 while の do の中で本番 DB', f'while true; do {T} -h {PROD} -p 5432 -c "{SEL}"; sleep 5; done', policy.DENY),
155
+ ('m3 if の then の中で DB の反映', f'if true; then {CENTRAL} db push; fi', policy.DENY),
156
+ ('m4 $() の中で本番 DB', f'until [ -n "$({T} -h {PROD} -p 5432 -c \'{SEL}\')" ]; do sleep 5; done', policy.DENY),
157
+ ('m5 for の語の並びの $() で本番 DB', f'for i in $({T} -h {PROD} -c "{SEL}"); do echo $i; done', policy.DENY),
158
+ ('m6 待ちのあとに本番反映', f'until gh run list --limit 1 | grep -q completed; do sleep 15; done; {HOSTING} --prod', policy.GATE),
159
+ ('m7 繰り返しの中で本番反映', f'until {HOSTING} --prod; do sleep 15; done', policy.GATE),
160
+ ('m8 繰り返しの中で保護を迂回する合流', 'until gh pr merge 12 --admin --merge; do sleep 15; done', policy.DENY),
161
+ ('m9 繰り返しの中で本番ブランチへ push', 'until gh run list --limit 1 | grep -q completed; do git push origin HEAD:main; sleep 15; done', policy.DENY),
162
+ ('m10 繰り返しの中で秘密を読む', 'while true; do cat .env; sleep 5; done', policy.DENY),
163
+ ('m11 繰り返しの中で秘密へ書く', 'until gh run list --limit 1 | grep -q completed; do echo x > .env; sleep 15; done', policy.DENY),
164
+ ('m12 知らない道具に SQL の字面(迷う形は今までどおり)', f'until node run.mjs "{W_GRANT} select on t to anon"; do sleep 5; done', policy.GATE),
165
+ ('m13 echo の中の $() で DB の反映', f'echo "$({CENTRAL} db push)"', policy.DENY),
166
+ ]
167
+ for label, cmd, want in MIXED:
168
+ check(label, decide(cmd), want)
169
+
170
+ # ─────────────────────────────────────────────────────────────────────────────
171
+ # (n) 2026-09-21 の形(便 4a7992d6・49cc6ddc)
172
+ # ─────────────────────────────────────────────────────────────────────────────
173
+ check('n1 node で読むだけの照合', decide('node scripts/migration-verify/compare.mjs expected.csv actual.md'), policy.ALLOW)
174
+ check('n2 照合・名前に grant',
175
+ decide(f'node scripts/migration-verify/compare.mjs docs/expected.csv out/prod-after-{W_GRANT}.md'), policy.ALLOW)
176
+
177
+ # ─────────────────────────────────────────────────────────────────────────────
178
+ # (v) 空振り確認 =直した所を外すと期待が崩れる(★戻したあと、もとの期待に戻ることも見る)
179
+ # ─────────────────────────────────────────────────────────────────────────────
180
+ saved = policy._port_forward_danger
181
+ policy._port_forward_danger = lambda command, depth=0: None
182
+ check('★空振り①: 単独の転送の判定を外すと t5 が通ってしまう', decide(SIX[4][1]), policy.ALLOW)
183
+ policy._port_forward_danger = saved
184
+ check('★空振り①の戻し: いまの決まりでは止まる', decide(SIX[4][1]), policy.DENY)
185
+
186
+ saved = policy._SHELL_KEYWORDS
187
+ policy._SHELL_KEYWORDS = frozenset()
188
+ check('★空振り②: 予約語を剥がさないと w9 を人に聞いてしまう', decide(WAIT_ALLOW[8][1]), policy.GATE)
189
+ check('★空振り②: 予約語を剥がさないと m1 が通ってしまう', decide(MIXED[0][1]), policy.ALLOW)
190
+ policy._SHELL_KEYWORDS = saved
191
+ check('★空振り②の戻し: w9 は通る', decide(WAIT_ALLOW[8][1]), policy.ALLOW)
192
+ check('★空振り②の戻し: m1 は止まる', decide(MIXED[0][1]), policy.DENY)
193
+
194
+ saved = policy._subst_payloads
195
+ policy._subst_payloads = lambda text: []
196
+ check('★空振り③: $() の中を測らないと m4 が通ってしまう', decide(MIXED[3][1]), policy.ALLOW)
197
+ policy._subst_payloads = saved
198
+ check('★空振り③の戻し: m4 は止まる', decide(MIXED[3][1]), policy.DENY)
199
+
200
+ print('=' * 72)
201
+ if failures:
202
+ print(f'NG {len(failures)} 件')
203
+ for f in failures:
204
+ print(' ' + f)
205
+ sys.exit(1)
206
+ print('すべて期待どおり')