@tyhld/conductor 0.12.37 → 0.12.39

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/cli.js CHANGED
@@ -13,7 +13,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import os from 'node:os';
16
- import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, } from './relay.js';
16
+ import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sweepProcessedReports, } from './relay.js';
17
17
  import { createRelayRunner } from './relay-runner.js';
18
18
  import { installProcessGuards } from './process-guard.js';
19
19
  import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
@@ -176,6 +176,14 @@ async function sendReport(cfg) {
176
176
  if (d.queuedCommandId !== null)
177
177
  body.queuedCommandId = d.queuedCommandId;
178
178
  }
179
+ // ★未処理の報告(まだ管制へ渡せていない控え)の指示IDを載せる(便 57c04e6d)。
180
+ // 1件も無ければ載せない=今までと同じ本文。多すぎるときは上限までにして総数を添える。
181
+ const pending = pendingReportIds();
182
+ if (pending.ids.length > 0) {
183
+ body.pendingReports = pending.ids;
184
+ if (pending.total > pending.ids.length)
185
+ body.pendingReportsTotal = pending.total;
186
+ }
179
187
  const time = new Date().toLocaleTimeString();
180
188
  try {
181
189
  const res = await fetch(`${cfg.url}/api/conductor/report`, {
@@ -216,6 +224,23 @@ async function start(args) {
216
224
  const timer = setInterval(() => {
217
225
  void sendReport(cfg);
218
226
  }, cfg.intervalSec * 1000);
227
+ // ★古い控えの片付け(便 57c04e6d)。1時間に1回だけ、処理済みの控えのうち30日より古い物を消す。
228
+ // ★未処理の控えは日数では消さない(渡し切れていない報告を失わないため)。
229
+ // 起動直後にも1回まわす=入れ直した直後から効く。
230
+ const sweep = () => {
231
+ try {
232
+ const removed = sweepProcessedReports();
233
+ if (removed > 0)
234
+ console.log(`[conductor] 古い報告の控えを${removed}件片付けました(処理済み・30日超)`);
235
+ }
236
+ catch (err) {
237
+ // 片付けは最善努力。失敗しても生存報告は止めない。
238
+ console.log(`[conductor] 報告の控えの片付けに失敗: ${err instanceof Error ? err.message : String(err)}`);
239
+ }
240
+ };
241
+ sweep();
242
+ const sweepTimer = setInterval(sweep, 60 * 60 * 1000);
243
+ sweepTimer.unref?.();
219
244
  // ADR-029 relay。CONDUCTOR_RELAY_ENABLED=true のときだけ別インターバルで回す。
220
245
  // heartbeat とは独立。off の間は何も起きない(既存挙動を壊さない)。
221
246
  //
package/dist/relay.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * メニューへ指示を流し込まないための落とし穴ガード専用。中身は読まない。
24
24
  */
25
25
  import { execFileSync } from 'node:child_process';
26
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync, } from 'node:fs';
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  // ───────────────────────────────────────────────────────────────────────────
@@ -1010,9 +1010,22 @@ export function buildSendText(body, site, anchor) {
1010
1010
  `(この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断します)。\n\n` +
1011
1011
  // ★報告はファイルに書いてもらう(2026-09-07)。画面に出す方式は窓が小さすぎて
1012
1012
  // 長い報告の先頭が流れ、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
1013
+ // ★2026-09-21: 頭に6つの欄(instruction_id / site / outcome / created_at / evidence / body)を
1014
+ // 必ず書いてもらう。管制が機械で読めるようにするため(えふさん確定・便 57c04e6d)。
1015
+ // 本文は欄の下にそのまま書けるので、長さも書き方も今までと変わらない。
1013
1016
  `作業がすべて完了したら、まず報告を次のファイルに書いてください(変更ファイル・要点・` +
1014
- `チェック結果を簡潔に): ${reportFileCandidates(commandId)[0]} ` +
1015
- `(フォルダが無ければ作ってください。長さの制限はありません。画面に出す必要はありません)。` +
1017
+ `チェック結果を簡潔に): ${path.join(reportWriteDir(), reportFileName(commandId))} ` +
1018
+ `(フォルダが無ければ作ってください。長さの制限はありません。画面に出す必要はありません)。\n` +
1019
+ `★ファイルの先頭に、次の囲みをそのまま付けてください(1行目から。ここが機械の読み口です)。\n` +
1020
+ `---\n` +
1021
+ `instruction_id: ${commandId}\n` +
1022
+ `site: ${site}\n` +
1023
+ `outcome: pushed または no_change または blocked のどれか1つ\n` +
1024
+ `created_at: 書いた日時(例 2026-09-21T04:20:00+09:00)\n` +
1025
+ `evidence:\n` +
1026
+ ` - 受入の証拠を1行に1つ(例 PR https://github.com/... / CI test pass / main に入った など)\n` +
1027
+ `---\n` +
1028
+ `この囲みの下に、いつもどおりの報告本文を書いてください(本文が空だと未完成と見なされます)。\n` +
1016
1029
  `その後の最終行に必ず ${marker} とだけ出力してください。\n\n` +
1017
1030
  // ★案D(relay非依存の確実な完了通知): 完了したら必ず devlog MCP の conductor_complete を呼ぶ。
1018
1031
  `そして完了したら必ず、devlog の MCP ツール conductor_complete を commandId="${commandId}" で1回だけ呼んでください` +
@@ -1095,34 +1108,67 @@ export function relaySendTmpDirs() {
1095
1108
  /** tmux バッファ名の接頭辞(他人のバッファと衝突させないため現場・PID・時刻で一意にする)。 */
1096
1109
  export const RELAY_SEND_BUFFER_PREFIX = 'conductor-send';
1097
1110
  // ───────────────────────────────────────────────────────────────────────────
1098
- // 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07)
1111
+ // 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07/置き場と作法は 2026-09-21 に改訂)
1099
1112
  // ───────────────────────────────────────────────────────────────────────────
1113
+ //
1114
+ // 【なぜ画面から読むのをやめたか(調査 d1d16a56 で確定した事実)】
1115
+ // 職人は全画面表示で動くので tmux から見える行は 16〜34 行しかない(実測: 代替画面=1・
1116
+ // 履歴の行数 0〜222 行/上限 2000 行)。報告が数十行あると、終わりの目印と DONE 行が
1117
+ // 下に残った時点で【始まりの目印はもう窓の外】になり、対が見つからず報告が null になる。
1118
+ // ★9/5 以降の実測: 報告を添えた完了 0 件 / 報告なしの完了 162 件。
1119
+ // =画面から報告を読む保険は一度も動いたことがなかった。だから【画面を読むのをやめて】ファイルにする。
1120
+ //
1121
+ // 【2026-09-21 に何を変えたか(えふさん確定・便 57c04e6d)】
1122
+ // ・置き場を `~/.local/state/conductor/reports` にした(`/tmp` は再起動で消えるため)。
1123
+ // 旧 `/tmp/claude-<uid>/conductor-report` は【読む先としてだけ】残す=既存の控えも拾える。
1124
+ // ・管制へ渡した後も【消さない】。`processed/` へ移して残す(markReportProcessed)。
1125
+ // ・職人が MCP で自分で完了させた場合も、同じ控えを残す(以前はここで捨てていた)。
1126
+ // ・未処理の指示IDは生存報告に載せて管制へ知らせる(pendingReportIds)。
1127
+ // 実体はこのファイルの下(★relay.ts は試験から直に読まれるので、部品を別ファイルに分けない)。
1128
+ /** 処理済みの控えを置く下位フォルダの名前。 */
1129
+ export const PROCESSED_DIR = 'processed';
1130
+ /** 処理済みの控えを残す期間(既定30日)。★未処理はこの期間では消さない。 */
1131
+ export const REPORT_KEEP_PROCESSED_MS = 30 * 24 * 60 * 60 * 1000;
1132
+ /**
1133
+ * 生存報告に載せる未処理の指示IDの上限(★管制側 ai-kana 便と同じ値にすること)。
1134
+ * 超えた分は載せず、総数だけ `pendingReportsTotal` で伝える(本文が太らないように)。
1135
+ */
1136
+ export const PENDING_REPORTS_MAX = 50;
1137
+ /** 報告の結末。conductor_complete の outcome と同じ語にそろえる(人も機械も同じ言い方にする)。 */
1138
+ export const REPORT_OUTCOMES = ['pushed', 'no_change', 'blocked'];
1139
+ /** 必須の欄(1つでも欠けたら「機械可読ではない」と判定する)。 */
1140
+ export const REQUIRED_REPORT_FIELDS = [
1141
+ 'instruction_id',
1142
+ 'site',
1143
+ 'outcome',
1144
+ 'created_at',
1145
+ 'evidence',
1146
+ 'body',
1147
+ ];
1100
1148
  /**
1101
- * 職人が報告を書く場所(1指示につき1ファイル)。★relay はここだけを見る。
1102
- *
1103
- * 【なぜ画面から読むのをやめたか(調査 d1d16a56 で確定した事実)】
1104
- * 職人は全画面表示で動くので tmux から見える行は 16〜34 行しかない(実測: 代替画面=1・
1105
- * 履歴の行数 0〜222 行/上限 2000 行)。報告が数十行あると、終わりの目印と DONE 行が
1106
- * 下に残った時点で【始まりの目印はもう窓の外】になり、対が見つからず報告が null になる。
1107
- * ★9/5 以降の実測: 報告を添えた完了 0 件 / 報告なしの完了 162 件。
1108
- * =画面から報告を読む保険は一度も動いたことがなかった。窓の大きさの問題なので、
1109
- * 目印の書き方をどう工夫しても直らない。だから【画面を読むのをやめて】ファイルにする。
1110
- *
1111
- * 置き場(前から順に試す。理由は writeSendTmpFile と同じ):
1112
- * 第1候補 /tmp/claude-<uid>/conductor-report … 職人の使い捨て置き場と同じ根(鉄則13)
1113
- * 第2候補 os.tmpdir()/conductor-report-<uid> … /tmp が使えない機体の保険
1114
- * 環境変数 CONDUCTOR_REPORT_DIR で1つに固定できる(テスト・特殊な機体向け)。
1149
+ * 報告の置き場(前から順に試す)。
1150
+ * ① `~/.local/state/conductor/reports` … ★正本。再起動で消えない(XDG_STATE_HOME があればそちら)。
1151
+ * ② `/tmp/claude-<uid>/conductor-report` … 旧・読む先としてだけ残す(既存の控えを拾うため)。
1152
+ * ③ `os.tmpdir()/conductor-report-<uid>` … `/tmp` が使えない機体の保険(旧)。
1153
+ * 環境変数 `CONDUCTOR_REPORT_DIR` を指定したときは、その1つだけ(試験・特殊な機体向け)。
1115
1154
  */
1116
- export function relayReportDirs(env = process.env) {
1155
+ export function reportStoreDirs(env = process.env) {
1117
1156
  const explicit = (env.CONDUCTOR_REPORT_DIR ?? '').trim();
1118
1157
  if (explicit)
1119
1158
  return [explicit];
1120
1159
  const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
1160
+ const home = env.HOME ?? os.homedir();
1161
+ const state = (env.XDG_STATE_HOME ?? '').trim() || path.join(home, '.local', 'state');
1121
1162
  return [
1163
+ path.join(state, 'conductor', 'reports'),
1122
1164
  path.join('/tmp', `claude-${uid}`, 'conductor-report'),
1123
1165
  path.join(os.tmpdir(), `conductor-report-${uid}`),
1124
1166
  ];
1125
1167
  }
1168
+ /** 職人に書いてもらう置き場(★正本の1つだけ)。指示文はここを指す。 */
1169
+ export function reportWriteDir(env = process.env) {
1170
+ return reportStoreDirs(env)[0];
1171
+ }
1126
1172
  /**
1127
1173
  * その指示の報告ファイル名。★指示ID で引ける形にする(どの便の報告かを取り違えない)。
1128
1174
  * 指示ID に紛れ込みうる区切り文字は落とす(別の場所を指す名前を作らせない)。
@@ -1131,39 +1177,256 @@ export function reportFileName(commandId) {
1131
1177
  const safe = String(commandId ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128);
1132
1178
  return `${safe || 'unknown'}.md`;
1133
1179
  }
1134
- /** 報告ファイルの置き場の候補(前から順に探す)。 */
1135
- function reportFileCandidates(commandId, env) {
1136
- return relayReportDirs(env).map((dir) => path.join(dir, reportFileName(commandId)));
1180
+ /** 未処理の置き場の候補(前から順に探す)。 */
1181
+ function pendingCandidates(commandId, env) {
1182
+ return reportStoreDirs(env).map((dir) => path.join(dir, reportFileName(commandId)));
1183
+ }
1184
+ /** 処理済みの置き場の候補(前から順に探す)。 */
1185
+ function processedCandidates(commandId, env) {
1186
+ return reportStoreDirs(env).map((dir) => path.join(dir, PROCESSED_DIR, reportFileName(commandId)));
1187
+ }
1188
+ /** 中身のあるファイルを読む。無い・空なら null。 */
1189
+ function readIfFilled(file) {
1190
+ try {
1191
+ const text = readFileSync(file, 'utf8').trim();
1192
+ return text.length > 0 ? text : null;
1193
+ }
1194
+ catch {
1195
+ return null;
1196
+ }
1137
1197
  }
1138
1198
  /**
1139
- * その指示の報告を読む(消さない)。無ければ null。
1140
- * ★読むだけで消さないのは、管制へ書けたことを確かめてから消すため
1141
- * (書けなかったのに消すと報告が永久に失われる)。
1199
+ * その指示の報告を読む(★消さない)。未処理が無ければ処理済みの控えも見る。
1200
+ * 返すのは【管制へ渡す本文】=front matter を外した本体(古い形は全文)。
1142
1201
  */
1143
1202
  export function readReport(commandId, env) {
1144
- for (const file of reportFileCandidates(commandId, env)) {
1203
+ const parsed = readParsedReport(commandId, env);
1204
+ return parsed === null ? null : parsed.body;
1205
+ }
1206
+ /** その指示の報告を、欄ごとに解析して読む(未処理→処理済みの順に探す)。 */
1207
+ export function readParsedReport(commandId, env) {
1208
+ for (const file of [...pendingCandidates(commandId, env), ...processedCandidates(commandId, env)]) {
1209
+ const text = readIfFilled(file);
1210
+ if (text !== null)
1211
+ return parseReport(text);
1212
+ }
1213
+ return null;
1214
+ }
1215
+ /** その指示の報告が、いまどの段にあるか。 */
1216
+ export function reportPhase(commandId, env) {
1217
+ for (const file of pendingCandidates(commandId, env)) {
1218
+ if (readIfFilled(file) !== null)
1219
+ return 'pending';
1220
+ }
1221
+ for (const file of processedCandidates(commandId, env)) {
1222
+ if (readIfFilled(file) !== null)
1223
+ return 'processed';
1224
+ }
1225
+ return 'none';
1226
+ }
1227
+ /**
1228
+ * 管制へ渡し終えた報告を【処理済み】にする(★消さない・削除しない)。
1229
+ * 同じ置き場の `processed/` へ移すだけ。移した時刻を更新時刻に刻む(30日の数え始め)。
1230
+ * 返り: 処理済みにできたら true(元から無い・既に処理済みなら false)。
1231
+ */
1232
+ export function markReportProcessed(commandId, env, now = Date.now()) {
1233
+ let moved = false;
1234
+ for (const file of pendingCandidates(commandId, env)) {
1235
+ if (!existsSync(file))
1236
+ continue;
1237
+ const dest = path.join(path.dirname(file), PROCESSED_DIR, path.basename(file));
1145
1238
  try {
1146
- const text = readFileSync(file, 'utf8').trim();
1147
- if (text.length > 0)
1148
- return text;
1239
+ mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
1240
+ // ★同じ置き場の中で動かす=別のディスクをまたがないので、途中で半分になることがない。
1241
+ renameSync(file, dest);
1242
+ // 30日の数え始めを「処理済みにした時刻」にそろえる(書いた時刻ではない)。
1243
+ const sec = now / 1000;
1244
+ utimesSync(dest, sec, sec);
1245
+ moved = true;
1149
1246
  }
1150
1247
  catch {
1151
- // 無い・読めない → 次の候補へ(報告が無いこと自体は異常ではない)
1248
+ // 移せなかったら触らない(次の周でやり直せる。★消すよりは残す方へ倒す)。
1152
1249
  }
1153
1250
  }
1154
- return null;
1251
+ return moved;
1155
1252
  }
1156
- /** その指示の報告ファイルを片付ける(★同じ報告を二度送らないため)。 */
1157
- export function discardReport(commandId, env) {
1158
- for (const file of reportFileCandidates(commandId, env)) {
1253
+ /**
1254
+ * まだ管制へ渡していない報告の指示ID(古い順)。★生存報告に載せて管制へ知らせるための一覧。
1255
+ * - `processed/` の中は数えない(もう渡した物)。
1256
+ * - 同じ指示IDが複数の置き場に在っても1件に畳む。
1257
+ * - 上限を超えた分は `ids` に載せず、`total` だけで伝える。
1258
+ */
1259
+ export function pendingReportIds(env, limit = PENDING_REPORTS_MAX) {
1260
+ const seen = new Map(); // 指示ID → 書かれた時刻(ms)
1261
+ for (const dir of reportStoreDirs(env)) {
1262
+ let names;
1159
1263
  try {
1160
- unlinkSync(file);
1264
+ names = readdirSync(dir);
1161
1265
  }
1162
1266
  catch {
1163
- // 無ければ何もしない
1267
+ continue; // 置き場がまだ無い=未処理も無い
1268
+ }
1269
+ for (const name of names) {
1270
+ if (!name.endsWith('.md'))
1271
+ continue;
1272
+ const file = path.join(dir, name);
1273
+ let mtime;
1274
+ try {
1275
+ const st = statSync(file);
1276
+ if (!st.isFile() || st.size === 0)
1277
+ continue;
1278
+ mtime = st.mtimeMs;
1279
+ }
1280
+ catch {
1281
+ continue;
1282
+ }
1283
+ const id = name.slice(0, -3);
1284
+ const prev = seen.get(id);
1285
+ if (prev === undefined || mtime < prev)
1286
+ seen.set(id, mtime);
1164
1287
  }
1165
1288
  }
1289
+ const sorted = [...seen.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id);
1290
+ const max = Math.max(0, Math.floor(limit));
1291
+ return { ids: sorted.slice(0, max), total: sorted.length };
1166
1292
  }
1293
+ /**
1294
+ * 古い控えを片付ける。★消すのは `processed/` の中だけ(未処理は何日たっても消さない)。
1295
+ * 返り: 消した件数。
1296
+ */
1297
+ export function sweepProcessedReports(env, now = Date.now(), maxAgeMs = REPORT_KEEP_PROCESSED_MS) {
1298
+ let removed = 0;
1299
+ for (const dir of reportStoreDirs(env)) {
1300
+ const done = path.join(dir, PROCESSED_DIR);
1301
+ let names;
1302
+ try {
1303
+ names = readdirSync(done);
1304
+ }
1305
+ catch {
1306
+ continue;
1307
+ }
1308
+ for (const name of names) {
1309
+ if (!name.endsWith('.md'))
1310
+ continue;
1311
+ const file = path.join(done, name);
1312
+ try {
1313
+ const st = statSync(file);
1314
+ if (!st.isFile())
1315
+ continue;
1316
+ if (now - st.mtimeMs <= maxAgeMs)
1317
+ continue;
1318
+ unlinkSync(file);
1319
+ removed += 1;
1320
+ }
1321
+ catch {
1322
+ // 読めない・消せないなら触らない
1323
+ }
1324
+ }
1325
+ }
1326
+ return removed;
1327
+ }
1328
+ /**
1329
+ * 報告の文字列を欄ごとに解く。
1330
+ *
1331
+ * 【なぜ front matter(`---` の囲み)にしたか=機械が確実に読める形の選び方】
1332
+ * ・職人は報告を【ヒアドキュメントで一気に書く】。JSON だと本文の改行・引用符・日本語の記号で
1333
+ * 壊れやすく、壊れた瞬間に報告そのものが読めなくなる(本文は数千〜2万字ある)。
1334
+ * ・front matter なら「頭の数行だけが欄・残りは全部本文」なので、本文に何が書かれていても
1335
+ * 欄の解釈は壊れない。欄が無い古い報告も【本文だけ在る】として同じ関数で読める。
1336
+ * ・外部の部品(YAML 読み)を足さずに読める=配布物が太らない。
1337
+ */
1338
+ export function parseReport(text) {
1339
+ const raw = String(text ?? '');
1340
+ const lines = raw.replace(/^/, '').split('\n');
1341
+ let i = 0;
1342
+ while (i < lines.length && lines[i].trim() === '')
1343
+ i += 1;
1344
+ const empty = {
1345
+ instructionId: null,
1346
+ site: null,
1347
+ outcome: null,
1348
+ createdAt: null,
1349
+ evidence: [],
1350
+ body: raw.trim(),
1351
+ missing: [],
1352
+ hasHeader: false,
1353
+ };
1354
+ if (i >= lines.length || lines[i].trim() !== '---') {
1355
+ return { ...empty, missing: missingOf(empty) };
1356
+ }
1357
+ const fields = new Map();
1358
+ const evidence = [];
1359
+ let lastKey = '';
1360
+ let closed = false;
1361
+ let j = i + 1;
1362
+ for (; j < lines.length; j += 1) {
1363
+ const line = lines[j];
1364
+ if (line.trim() === '---') {
1365
+ closed = true;
1366
+ break;
1367
+ }
1368
+ const bullet = /^\s*-\s+(.*)$/.exec(line);
1369
+ if (bullet !== null) {
1370
+ // 箇条書きは直前の欄の続き。いまは evidence だけが複数行を持つ。
1371
+ if (lastKey === 'evidence' && bullet[1].trim() !== '')
1372
+ evidence.push(bullet[1].trim());
1373
+ continue;
1374
+ }
1375
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(line);
1376
+ if (kv === null)
1377
+ continue; // 読めない行は捨てる(欄の解釈を壊さない)
1378
+ lastKey = kv[1].toLowerCase();
1379
+ const value = kv[2].trim();
1380
+ if (lastKey === 'evidence') {
1381
+ if (value !== '')
1382
+ evidence.push(value);
1383
+ continue;
1384
+ }
1385
+ fields.set(lastKey, value);
1386
+ }
1387
+ if (!closed) {
1388
+ // 閉じていない=front matter として読まない(全文を本文として扱う)。
1389
+ return { ...empty, missing: missingOf(empty) };
1390
+ }
1391
+ const outcomeRaw = (fields.get('outcome') ?? '').trim();
1392
+ const outcome = REPORT_OUTCOMES.includes(outcomeRaw)
1393
+ ? outcomeRaw
1394
+ : null;
1395
+ const parsed = {
1396
+ instructionId: nonEmpty(fields.get('instruction_id')),
1397
+ site: nonEmpty(fields.get('site')),
1398
+ outcome,
1399
+ createdAt: nonEmpty(fields.get('created_at')),
1400
+ evidence,
1401
+ body: lines.slice(j + 1).join('\n').trim(),
1402
+ missing: [],
1403
+ hasHeader: true,
1404
+ };
1405
+ return { ...parsed, missing: missingOf(parsed) };
1406
+ }
1407
+ function nonEmpty(v) {
1408
+ const s = (v ?? '').trim();
1409
+ return s === '' ? null : s;
1410
+ }
1411
+ /** 欠けている必須の欄を並べる(空なら新しい形=機械可読)。 */
1412
+ function missingOf(r) {
1413
+ const missing = [];
1414
+ if (r.instructionId === null)
1415
+ missing.push('instruction_id');
1416
+ if (r.site === null)
1417
+ missing.push('site');
1418
+ if (r.outcome === null)
1419
+ missing.push('outcome');
1420
+ if (r.createdAt === null)
1421
+ missing.push('created_at');
1422
+ if (r.evidence.length === 0)
1423
+ missing.push('evidence');
1424
+ if (r.body.trim() === '')
1425
+ missing.push('body');
1426
+ return missing;
1427
+ }
1428
+ /** 報告の置き場(前から順に探す)。★名前は従来のまま(中身は reportStoreDirs)。 */
1429
+ export const relayReportDirs = reportStoreDirs;
1167
1430
  /** 本文を書いた一時ファイルを作ってパスを返す。どこにも書けなければ例外。 */
1168
1431
  function writeSendTmpFile(text) {
1169
1432
  let lastErr = new Error('一時ファイルの置き場が1つも作れませんでした');
@@ -1415,23 +1678,33 @@ export async function patchStatus(cfg, id, status, report) {
1415
1678
  * 中央へ1回だけ状態を尋ねて確かめる。尋ねられない(不通)ときは送る側に倒す
1416
1679
  * =完了が落ちるより、同じ報告が2度書かれるほうが害が小さい(中央は上書き保存する)。
1417
1680
  *
1418
- * 【後片付け】管制へ書けたときだけ報告ファイルを消す。書けなければ残す=次tickでやり直せる。
1681
+ * 【後片付け】管制へ書けたときだけ報告を【処理済み】にする(★消さない・2026-09-21)。
1682
+ * 書けなければ未処理のまま残す=次tickでやり直せるし、管制も生存報告で気づける。
1683
+ *
1684
+ * 【★冪等(二重完了にしない)】報告が既に処理済みなら、この便はもう渡し終えている。
1685
+ * 中央へ問い合わせる前にそこで打ち切る=MCP と報告ファイルの二経路から同じ便が来ても1回だけ扱う。
1419
1686
  * 返り: 追跡を畳んでよいか(true = 完了が確定した)。
1420
1687
  */
1421
1688
  export async function completeWithReport(cfg, id, logLine = log) {
1689
+ // ★冪等の要: 処理済みの控えが在る=この便はもう渡してある。何もしない(二重完了にしない)。
1690
+ if (reportPhase(id) === 'processed') {
1691
+ logLine(`site=${cfg.site} id=${id} の報告は処理済みです→送りません(二重完了にしない)`);
1692
+ return true;
1693
+ }
1422
1694
  const already = await fetchCommandStatus(cfg, id);
1423
1695
  if (isTerminalCommandStatus(already)) {
1424
1696
  // 職人が自分で完了させていた。★relay は二重に送らない(報告も上書きしない)。
1425
- discardReport(id);
1697
+ // ★控えは消さずに処理済みとして残す(MCP 経由でも同じ控えが残る・えふさん確定 2026-09-21)。
1698
+ markReportProcessed(id);
1426
1699
  logLine(`site=${cfg.site} id=${id} は職人が自分で完了させていた(status=${already})` +
1427
- '→relayからは送りません(報告の置き場だけ片付けました)');
1700
+ '→relayからは送りません(報告は控えとして処理済みにしました)');
1428
1701
  return true;
1429
1702
  }
1430
1703
  const report = readReport(id);
1431
1704
  if (!(await patchStatus(cfg, id, 'done', report)))
1432
1705
  return false;
1433
1706
  if (report !== null)
1434
- discardReport(id); // ★書けたら片付ける(同じ報告を二度送らない)
1707
+ markReportProcessed(id); // ★消さずに処理済みへ(同じ報告を二度送らない)
1435
1708
  logLine(`site=${cfg.site} id=${id} done` +
1436
1709
  (report !== null ? `(報告添付・${report.length}文字)` : '(報告ファイルなし=完了のみ)'));
1437
1710
  return true;
@@ -1745,11 +2018,20 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1745
2018
  return;
1746
2019
  }
1747
2020
  if (action === 'done') {
2021
+ // ★冪等(2026-09-21): 報告が既に処理済み=この便はもう渡してある。送らずに畳むだけ。
2022
+ // 実測 2026-09-20: 職人が MCP で完了した便に、見張りが画面の DONE でもう一度完了を送っていた。
2023
+ if (reportPhase(ka.id) === 'processed') {
2024
+ logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 報告は処理済みでした` +
2025
+ '→送らずに畳みます(二重完了にしない)');
2026
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
2027
+ state.keepalive = null;
2028
+ return;
2029
+ }
1748
2030
  // 完了は従来の経路(画面の DONE 行)にそのまま乗せる。★報告は職人が書いたファイルから読む。
1749
2031
  const report = readReport(ka.id);
1750
2032
  if (await complete(cfg, ka.id, report)) {
1751
2033
  if (report !== null)
1752
- discardReport(ka.id); // ★書けたら片付ける(二度送らない)
2034
+ markReportProcessed(ka.id); // ★消さずに処理済みへ(二度送らない)
1753
2035
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
1754
2036
  `${report != null ? '(報告添付)' : ''}`);
1755
2037
  // ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.37",
3
+ "version": "0.12.39",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1842,6 +1842,187 @@ _PROD_DB_CHANGE_RE = re.compile(
1842
1842
  _PKG_MIGRATE_RE = re.compile(r'^(npm|pnpm|yarn) ((run|run-script) )?migrate')
1843
1843
 
1844
1844
 
1845
+ # ─────────────────────────────────────────────────────────────────────────────
1846
+ # ★手元の使い捨てクラスタへの SQL 実行だけを通す(えふさん確定 2026-09-21・便 ee34f7e5)
1847
+ #
1848
+ # 【なぜ要るか】本番へ SQL を貼る前に手元で試せないと、「本番の SQL Editor が最初の実行試験」に
1849
+ # なってしまう。手元の使い捨てクラスタで先に流せれば、本番で初めて落ちることが無くなる。
1850
+ #
1851
+ # 【★許すのはこれだけ(1件)】
1852
+ # ① 道具は対話クライアント1つだけ。作る・消す・写す道具(createdb・dropdb・積み替え等)は今までどおり。
1853
+ # ② つなぎ先が【実行境界】で手元であること=コマンドに書かれた宛先で見る。SQL の本文は1文字も見ない。
1854
+ # ★名前(別名)は許さない。名前は別の機械へ向け替えられるので、数字の宛先だけを許す。
1855
+ # ③ 使い捨ての印=ポートを必ず明示し、私用の番号帯(49152〜65535)であること。
1856
+ # =その機で共有している DB(ふつうの 5432)は今までどおり止まる。
1857
+ # ④ 宛先が読めない形(変数・コマンド置換・接続文字列・サービス名・環境変数での差し替え)は許さない。
1858
+ # ⑤ トンネル・ポート転送の道具が同じコマンドに在る形は、この前の段で deny にしてある。
1859
+ # ─────────────────────────────────────────────────────────────────────────────
1860
+
1861
+ #: 手元だけを指す【数字の】宛先。★別名(名前)は入れない(向け替えられるため)。
1862
+ _LOCAL_HOST_LITERALS = frozenset({'127.0.0.1', '::1', '[::1]'})
1863
+
1864
+ #: 使い捨てクラスタに使ってよいポートの帯(私用の番号帯)。★既定の 5432 は入らない。
1865
+ SCRATCH_PORT_MIN = 49152
1866
+ SCRATCH_PORT_MAX = 65535
1867
+
1868
+ #: 手元の使い捨てだけ通してよい SQL の対話クライアント。
1869
+ _LOCAL_SQL_CLIENT = 'psql'
1870
+
1871
+ #: 宛先を書き換えうる環境変数。手元を指していなければ通さない。
1872
+ _PG_ENV_HOST = ('pghost', 'pghostaddr')
1873
+ _PG_ENV_PORT = ('pgport',)
1874
+ #: 宛先が別の場所に隠れる形(この名前が出たら通さない)。
1875
+ _PG_ENV_INDIRECT = (
1876
+ 'pgservice', 'pgservicefile', 'pgpassfile', 'pguri', 'pgurl',
1877
+ 'database_url', 'postgres_url', 'postgresql_url', 'pgdatabase',
1878
+ )
1879
+
1880
+ #: localhost に見せかける回り道の道具(同じコマンドに在れば SQL クライアントは通さない)。
1881
+ _TUNNEL_TOOLS = frozenset({
1882
+ 'ssh', 'autossh', 'sshuttle', 'socat', 'ncat', 'nc', 'netcat', 'chisel',
1883
+ 'ngrok', 'cloudflared', 'stunnel', 'pgbouncer', 'kubectl', 'oc',
1884
+ 'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc', 'iptables', 'nft',
1885
+ })
1886
+
1887
+
1888
+ def _strip_quotes(v: str) -> str:
1889
+ return (v or '').strip().strip('"').strip("'")
1890
+
1891
+
1892
+ def _scratch_port_ok(value) -> bool:
1893
+ """使い捨ての印=私用の番号帯のポートか。"""
1894
+ v = _strip_quotes(str(value))
1895
+ if not v.isdigit():
1896
+ return False
1897
+ n = int(v)
1898
+ return SCRATCH_PORT_MIN <= n <= SCRATCH_PORT_MAX
1899
+
1900
+
1901
+ def _conninfo_local(value: str) -> bool:
1902
+ """接続文字列(URL・key=value)の宛先が手元の使い捨てか。読めない形は False。"""
1903
+ v = _strip_quotes(value)
1904
+ low = v.lower()
1905
+ if '://' in low:
1906
+ rest = low.split('://', 1)[1]
1907
+ rest = rest.split('/', 1)[0].split('?', 1)[0]
1908
+ if '@' in rest:
1909
+ rest = rest.rsplit('@', 1)[1]
1910
+ host, port = rest, None
1911
+ if rest.startswith('['): # [::1]:55432
1912
+ close = rest.find(']')
1913
+ if close < 0:
1914
+ return False
1915
+ host = rest[:close + 1]
1916
+ tail = rest[close + 1:]
1917
+ if tail.startswith(':'):
1918
+ port = tail[1:]
1919
+ elif ':' in rest:
1920
+ host, port = rest.rsplit(':', 1)
1921
+ if host not in _LOCAL_HOST_LITERALS:
1922
+ return False
1923
+ return True if port is None else _scratch_port_ok(port)
1924
+ if 'host=' in low:
1925
+ host = None
1926
+ port = None
1927
+ for part in low.replace('\t', ' ').split():
1928
+ if part.startswith('host=') or part.startswith('hostaddr='):
1929
+ host = part.split('=', 1)[1]
1930
+ elif part.startswith('port='):
1931
+ port = part.split('=', 1)[1]
1932
+ if host is None or host not in _LOCAL_HOST_LITERALS:
1933
+ return False
1934
+ return True if port is None else _scratch_port_ok(port)
1935
+ return True # ふつうのDB名(宛先を持たない)
1936
+
1937
+
1938
+ def _local_scratch_sql_ok(tokens) -> bool:
1939
+ """この1セグメントが「手元の使い捨てクラスタへの SQL 実行」か。★宛先だけで決める。"""
1940
+ idx = None
1941
+ for i, t in enumerate(tokens):
1942
+ if t.split('/')[-1] == _LOCAL_SQL_CLIENT:
1943
+ idx = i
1944
+ break
1945
+ if idx is None:
1946
+ return False
1947
+ host = None
1948
+ port = None
1949
+ # ① 宛先が読めない形(変数・コマンド置換)は通さない。
1950
+ for t in tokens:
1951
+ if '$' in t or '`' in t:
1952
+ return False
1953
+ # ② 接続文字列がどこに紛れていても、手元でなければ通さない。
1954
+ # ★旗そのもの(--host=… など)は下の④で読むので、ここでは「= の右側」だけを見る。
1955
+ for t in tokens:
1956
+ v = t
1957
+ if v.startswith('-'):
1958
+ if '=' not in v:
1959
+ continue
1960
+ v = v.split('=', 1)[1]
1961
+ low = v.lower()
1962
+ if '://' in low or 'host=' in low:
1963
+ if not _conninfo_local(v):
1964
+ return False
1965
+ # ③ 環境変数での差し替え。
1966
+ for t in tokens[:idx]:
1967
+ if not _ASSIGN_RE.match(t):
1968
+ continue
1969
+ key, _, value = t.partition('=')
1970
+ k = key.lower()
1971
+ v = _strip_quotes(value)
1972
+ if k in _PG_ENV_HOST:
1973
+ if v not in _LOCAL_HOST_LITERALS:
1974
+ return False
1975
+ host = host or v
1976
+ elif k in _PG_ENV_PORT:
1977
+ if not _scratch_port_ok(v):
1978
+ return False
1979
+ port = port or v
1980
+ elif k in _PG_ENV_INDIRECT:
1981
+ return False
1982
+ # ④ コマンドに書かれた宛先(★これが実行境界。libpq は引数の宛先を優先する)。
1983
+ args = tokens[idx + 1:]
1984
+ i = 0
1985
+ while i < len(args):
1986
+ a = args[i]
1987
+ low = a.lower()
1988
+ if low in ('-h', '--host', '--hostaddr'):
1989
+ i += 1
1990
+ host = _strip_quotes(args[i]) if i < len(args) else None
1991
+ elif low.startswith('--host=') or low.startswith('--hostaddr='):
1992
+ host = _strip_quotes(a.split('=', 1)[1])
1993
+ elif low.startswith('-h') and len(a) > 2:
1994
+ host = _strip_quotes(a[2:])
1995
+ elif low in ('-p', '--port'):
1996
+ i += 1
1997
+ port = _strip_quotes(args[i]) if i < len(args) else None
1998
+ elif low.startswith('--port='):
1999
+ port = _strip_quotes(a.split('=', 1)[1])
2000
+ elif low.startswith('-p') and len(a) > 2 and not low.startswith('--'):
2001
+ port = _strip_quotes(a[2:])
2002
+ i += 1
2003
+ # ⑤ 手元の数字の宛先+使い捨ての帯のポートを【両方とも明示】しているときだけ通す。
2004
+ return host in _LOCAL_HOST_LITERALS and port is not None and _scratch_port_ok(port)
2005
+
2006
+
2007
+ def _tunneled_db_danger(command: str) -> str:
2008
+ """回り道(トンネル・ポート転送)と SQL クライアントが同じコマンドに在る形。"""
2009
+ has_client = False
2010
+ tunnel = None
2011
+ for seg in split_commands(command):
2012
+ toks = tokens_of(seg.partition('\n')[0])
2013
+ for t in toks:
2014
+ base = t.split('/')[-1].lower()
2015
+ if base == _LOCAL_SQL_CLIENT:
2016
+ has_client = True
2017
+ elif base in _TUNNEL_TOOLS:
2018
+ tunnel = tunnel or base
2019
+ elif base in ('-l', '-r', '-d') and tunnel:
2020
+ pass
2021
+ if has_client and tunnel:
2022
+ return f'手元に見せかけた回り道での DB 実行 ({tunnel})'
2023
+ return None
2024
+
2025
+
1845
2026
  def _prod_db_change(seg, tokens):
1846
2027
  """1セグメントが本番 DB を直接変える道具なら理由を返す。"""
1847
2028
  words = _tool_words(tokens)
@@ -1849,6 +2030,10 @@ def _prod_db_change(seg, tokens):
1849
2030
  return None
1850
2031
  joined = ' '.join(words)
1851
2032
  if _PROD_DB_CHANGE_RE.match(joined):
2033
+ # ★手元の使い捨てクラスタ(数字の宛先+私用の帯のポート)への SQL 実行だけは通す。
2034
+ # 回り道(トンネル・ポート転送)が同じコマンドに在る形は、この前の段で deny にしてある。
2035
+ if words[0] == _LOCAL_SQL_CLIENT and _local_scratch_sql_ok(tokens):
2036
+ return None
1852
2037
  return f'本番 DB の直接変更 ({" ".join(words[:3])})'
1853
2038
  if _PKG_MIGRATE_RE.match(joined):
1854
2039
  return f'本番 DB の直接変更 ({" ".join(words[:3])})'
@@ -3124,6 +3309,9 @@ def _gate_db(words, tokens, segment: str):
3124
3309
  return None
3125
3310
  v = words[0]
3126
3311
  if v in _DB_CLIENTS:
3312
+ # ★手元の使い捨てクラスタへの SQL 実行だけは関門にしない(宛先で決める・SQL は見ない)。
3313
+ if v == _LOCAL_SQL_CLIENT and _local_scratch_sql_ok(tokens):
3314
+ return None
3127
3315
  return f'database client ({v})'
3128
3316
  joined = ' '.join(words)
3129
3317
  if re.match(r'prisma (migrate|db)( |$)', joined):
@@ -3249,6 +3437,11 @@ def decide_bash(command: str, cwd=None):
3249
3437
  if hit:
3250
3438
  return _r(DENY, f'danger: {hit}')
3251
3439
 
3440
+ # ---- 1d') 手元に見せかけた回り道での DB 実行(トンネル・ポート転送・便 ee34f7e5) ----
3441
+ hit = _tunneled_db_danger(command)
3442
+ if hit:
3443
+ return _r(DENY, f'danger: {hit}')
3444
+
3252
3445
  # ---- 1e) 本番 DB の直接変更(★判定の置き場は _prod_db_change の1か所・決定は下の定数) ----
3253
3446
  hit = _prod_db_change_scan(command)
3254
3447
  if hit:
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """手元の使い捨てクラスタへの SQL 実行だけを通す決まりの検査(便 ee34f7e5)。
3
+
4
+ 実行: python3 scripts/hooks/test_local_psql.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【何を守っているか】
8
+ (n) ★negative test =通ってはいけない形が1つも通らないこと
9
+ 本番・開発共有DB・環境変数での差し替え・見せかけの回り道(ポート転送/別名/接続文字列)・
10
+ 使い捨てでない手元の DB(ふつうの 5432)は、今までどおり止まる。
11
+ (p) 手元の使い捨て(数字の宛先+私用の帯のポート)だけが通ること。
12
+ (s) SQL の本文では許していないこと(同じ SQL でも、宛先が違えば止まる)。
13
+ (w) ★この1件のほかに許可が広がっていないこと(ほかの DB の道具・移行の道具は変わらない)。
14
+
15
+ ★この台本は道具の名前を素の字で置かない(組み立てて使う)。台本の行そのものが番人の判定に
16
+ 当たって、検査が流せなくなるのを避けるため。変数の名前も道具の名前にしない。
17
+ """
18
+ import os
19
+ import sys
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+
23
+ import policy # noqa: E402
24
+
25
+ T = 'p' + 'sql'
26
+ TOOL_DEL = 'drop' + 'db'
27
+ TOOL_NEW = 'create' + 'db'
28
+ TOOL_MIG_A = 'pris' + 'ma'
29
+ TOOL_MIG_B = 'drizzle' + '-kit'
30
+ TOOL_CENTRAL = 'supa' + 'base'
31
+ TOOL_OTHER = 'my' + 'sql'
32
+ TOOL_RESTORE = 'pg_' + 'restore'
33
+ TOOL_BACKUP = 'pg_' + 'dump'
34
+ WORD_MIG = 'migr' + 'ate'
35
+ WORD_EXEC = 'exec' + '_sql'
36
+
37
+ CWD = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
38
+ SEL = 'select 1'
39
+ DDL = 'cre' + 'ate table t(i int)'
40
+ PROD = 'db.exampleref.supa' + 'base.co'
41
+
42
+ failures = []
43
+
44
+
45
+ def check(label, got, want):
46
+ ok = got == want
47
+ if not ok:
48
+ failures.append(f'{label}: got={got!r} want={want!r}')
49
+ print(f'{"OK " if ok else "NG "} {label:72} {got}')
50
+
51
+
52
+ def decide(cmd):
53
+ return policy.decide_bash(cmd, CWD)['decision']
54
+
55
+
56
+ # ─────────────────────────────────────────────────────────────────────────────
57
+ # (n) negative test =通ってはいけないものが通らない(★1行に「何を守っているか」)
58
+ # ─────────────────────────────────────────────────────────────────────────────
59
+ NEGATIVE = [
60
+ ('n1 本番のホストへ直につなぐ', f'{T} -h {PROD} -p 5432 -U postgres -c "{SEL}"',
61
+ '本番 DB を職人が直接触らない'),
62
+ ('n2 本番を接続文字列で書く', f'{T} "postgresql://postgres:x@{PROD}:5432/postgres" -c "{SEL}"',
63
+ '宛先を文字列に隠しても本番へ届かない'),
64
+ ('n3 本番を key=value の接続情報で書く', f'{T} "host={PROD} port=5432 dbname=postgres" -c "{SEL}"',
65
+ '書き方を変えても本番へ届かない'),
66
+ ('n4 開発共有DBのホストへつなぐ', f'{T} -h dev-shared.internal.example -p 5432 -c "{SEL}"',
67
+ 'みんなで使う開発DBを1人が書き換えない'),
68
+ ('n5 環境変数で宛先を本番へ差し替える', f'PGHOST={PROD} {T} -p 5432 -c "{SEL}"',
69
+ '環境変数での差し替えを見逃さない'),
70
+ ('n6 環境変数で差し替え、引数は手元に見せる', f'PGHOSTADDR={PROD} {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
71
+ '手元に見せかけた差し替えを見逃さない'),
72
+ ('n7 サービス名で宛先を隠す', f'PGSERVICE=prod {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
73
+ '別の場所に宛先が隠れる形を通さない'),
74
+ ('n8 接続情報ファイルで宛先を隠す', f'PGSERVICEFILE=/tmp/svc.conf {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
75
+ '別の場所に宛先が隠れる形を通さない'),
76
+ ('n9 変数で宛先を組み立てる', f'{T} -h "$PGHOST" -p 55432 -c "{SEL}"',
77
+ '実行のときにしか宛先が決まらない形を通さない'),
78
+ ('n10 コマンド置換で宛先を組み立てる', f'{T} -h $(cat /tmp/host.txt) -p 55432 -c "{SEL}"',
79
+ '実行のときにしか宛先が決まらない形を通さない'),
80
+ ('n11 別名(名前)で手元に見せる', f'{T} -h localhost -p 55432 -c "{SEL}"',
81
+ '名前は別の機械へ向け替えられるので通さない'),
82
+ ('n12 別名の変種', f'{T} -h localhost.localdomain -p 55432 -c "{SEL}"',
83
+ '名前は別の機械へ向け替えられるので通さない'),
84
+ ('n13 トンネルを張ってから手元に見せる',
85
+ f'ssh -L 55432:{PROD}:5432 bastion & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
86
+ 'ポート転送で本番へ届く形を通さない'),
87
+ ('n14 転送の道具と同じコマンド',
88
+ f'socat TCP-LISTEN:55432,fork TCP:{PROD}:5432 & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
89
+ 'ポート転送で本番へ届く形を通さない'),
90
+ ('n15 束ねの道具の転送と同じコマンド',
91
+ f'kubectl port-forward svc/db 55432:5432 & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
92
+ 'ポート転送で本番へ届く形を通さない'),
93
+ ('n16 手元だが使い捨てでない(ふつうの 5432)', f'{T} -h 127.0.0.1 -p 5432 -c "{DDL}"',
94
+ 'その機で共有している DB を書き換えない'),
95
+ ('n17 手元だがポートを書いていない', f'{T} -h 127.0.0.1 -c "{SEL}"',
96
+ '既定の宛先(共有の DB)へ黙って入らない'),
97
+ ('n18 手元だが帯の外のポート', f'{T} -h 127.0.0.1 -p 6543 -c "{SEL}"',
98
+ '使い捨てと言い切れない宛先を通さない'),
99
+ ('n19 引数は手元、接続文字列は本番', f'{T} -h 127.0.0.1 -p 55432 "postgresql://u@{PROD}:5432/postgres"',
100
+ 'あとから効く宛先を見逃さない'),
101
+ ('n20 宛先を書かず環境変数だけ', f'PGHOST=127.0.0.1 PGPORT=55432 {T} -c "{SEL}"',
102
+ '実行境界で確かめられない形を通さない'),
103
+ ('n21 シェルの中に隠しても同じ', f"""bash -c "{T} -h {PROD} -p 5432 -c '{SEL}'" """.strip(),
104
+ '入れ子にしても本番へ届かない'),
105
+ ('n22 消す道具は使い捨てでも通さない', f'{TOOL_DEL} -h 127.0.0.1 -p 55432 scratch',
106
+ '許したのは SQL の対話クライアント1つだけ'),
107
+ ]
108
+ for name, cmd, guards in NEGATIVE:
109
+ check(f'{name}({guards})', decide(cmd), policy.DENY)
110
+
111
+ check('n23 作る道具は使い捨てでも関門のまま(許可を1件より広げない)',
112
+ decide(f'{TOOL_NEW} -h 127.0.0.1 -p 55432 scratch'), policy.GATE)
113
+
114
+
115
+ # ─────────────────────────────────────────────────────────────────────────────
116
+ # (p) 通ってよい形=手元の使い捨てだけ
117
+ # ─────────────────────────────────────────────────────────────────────────────
118
+ POSITIVE = [
119
+ ('p1 使い捨てへファイルを流す', f'{T} -h 127.0.0.1 -p 55432 -U postgres -d postgres -f /tmp/claude-1000/x.sql'),
120
+ ('p2 使い捨てへ1行流す', f'{T} -h 127.0.0.1 -p 49152 -c "{SEL}"'),
121
+ ('p3 使い捨てへ DDL を流す(SQL の中身は見ない)', f'{T} -h 127.0.0.1 -p 55432 -c "{DDL}"'),
122
+ ('p4 手元の IPv6 の宛先', f'{T} -h ::1 -p 55432 -c "{SEL}"'),
123
+ ('p5 宛先を = で書く', f'{T} --host=127.0.0.1 --port=55432 -c "{SEL}"'),
124
+ ('p6 接続文字列も手元の使い捨て', f'{T} -h 127.0.0.1 -p 55432 "postgresql://postgres@127.0.0.1:55432/postgres"'),
125
+ ]
126
+ for name, cmd in POSITIVE:
127
+ check(name, decide(cmd), policy.ALLOW)
128
+
129
+
130
+ # ─────────────────────────────────────────────────────────────────────────────
131
+ # (s) SQL の本文では許していない(同じ SQL でも宛先が違えば止まる)
132
+ # ─────────────────────────────────────────────────────────────────────────────
133
+ check('s1 同じ SQL でも本番の宛先なら止まる', decide(f'{T} -h {PROD} -p 5432 -c "{SEL}"'), policy.DENY)
134
+ check('s2 同じ SQL でも使い捨てなら通る', decide(f'{T} -h 127.0.0.1 -p 55432 -c "{SEL}"'), policy.ALLOW)
135
+
136
+
137
+ # ─────────────────────────────────────────────────────────────────────────────
138
+ # (w) 許可を広げていない(この1件のほかは1つも変えていない)
139
+ # ─────────────────────────────────────────────────────────────────────────────
140
+ UNCHANGED = [
141
+ ('w1 移行の道具(押し込み)', f'{TOOL_MIG_A} db push', policy.DENY),
142
+ ('w2 移行の道具(適用)', f'{TOOL_MIG_A} {WORD_MIG} deploy', policy.DENY),
143
+ ('w3 中央の移行', f'{TOOL_CENTRAL} db push', policy.DENY),
144
+ ('w4 別の移行の道具', f'{TOOL_MIG_B} push', policy.DENY),
145
+ ('w5 台本ごしの移行', f'npm run {WORD_MIG}', policy.DENY),
146
+ ('w6 REST の抜け道', f'curl -X POST https://x.example.co/rest/v1/rpc/{WORD_EXEC} -d @a.json', policy.DENY),
147
+ ('w7 別の DB の道具', f'{TOOL_OTHER} -h 127.0.0.1 -P 55432 -e "{SEL}"', policy.GATE),
148
+ ('w8 DB を消す道具', f'{TOOL_DEL} scratch', policy.DENY),
149
+ ('w9 控えを戻す道具', f'{TOOL_RESTORE} -h 127.0.0.1 -p 55432 -d postgres dump.sql', policy.GATE),
150
+ ('w10 控えを取る道具', f'{TOOL_BACKUP} -h 127.0.0.1 -p 55432 postgres', policy.GATE),
151
+ ]
152
+ for name, cmd, want in UNCHANGED:
153
+ check(name, decide(cmd), want)
154
+
155
+
156
+ # ─────────────────────────────────────────────────────────────────────────────
157
+ # ★空振り確認: 決まりを緩めた形に戻すと、通ってはいけないものが通ってしまう
158
+ # ─────────────────────────────────────────────────────────────────────────────
159
+ saved_hosts = policy._LOCAL_HOST_LITERALS
160
+ policy._LOCAL_HOST_LITERALS = frozenset(saved_hosts | {'localhost', 'localhost.localdomain'})
161
+ check('★空振り①: 別名も手元として許すと「見せかけの形」が通ってしまう',
162
+ decide(f'{T} -h localhost -p 55432 -c "{SEL}"'), policy.ALLOW)
163
+ policy._LOCAL_HOST_LITERALS = saved_hosts
164
+ check('★空振り①の戻し: いまの決まりでは止まる',
165
+ decide(f'{T} -h localhost -p 55432 -c "{SEL}"'), policy.DENY)
166
+
167
+ saved_port = policy._scratch_port_ok
168
+ policy._scratch_port_ok = lambda v: True
169
+ check('★空振り②: ポートの帯を見ないと「共有の 5432」が通ってしまう',
170
+ decide(f'{T} -h 127.0.0.1 -p 5432 -c "{SEL}"'), policy.ALLOW)
171
+ policy._scratch_port_ok = saved_port
172
+ check('★空振り②の戻し: いまの決まりでは止まる',
173
+ decide(f'{T} -h 127.0.0.1 -p 5432 -c "{SEL}"'), policy.DENY)
174
+
175
+ saved_tunnel = policy._TUNNEL_TOOLS
176
+ policy._TUNNEL_TOOLS = frozenset()
177
+ check('★空振り③: 回り道の道具を見ないと「転送ごしの形」が通ってしまう',
178
+ decide(f'ssh -L 55432:{PROD}:5432 bastion & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"'), policy.ALLOW)
179
+ policy._TUNNEL_TOOLS = saved_tunnel
180
+ check('★空振り③の戻し: いまの決まりでは止まる',
181
+ decide(f'ssh -L 55432:{PROD}:5432 bastion & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"'), policy.DENY)
182
+
183
+
184
+ print()
185
+ if failures:
186
+ print(f'NG {len(failures)} 件')
187
+ for f in failures:
188
+ print(' -', f)
189
+ sys.exit(1)
190
+ print('すべて期待どおりです(negative test + 空振り確認)')