@tyhld/conductor 0.12.38 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.38",
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 + 空振り確認)')