@tyhld/conductor 0.12.38 → 0.12.40
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
|
@@ -1107,6 +1107,23 @@ export function relaySendTmpDirs() {
|
|
|
1107
1107
|
}
|
|
1108
1108
|
/** tmux バッファ名の接頭辞(他人のバッファと衝突させないため現場・PID・時刻で一意にする)。 */
|
|
1109
1109
|
export const RELAY_SEND_BUFFER_PREFIX = 'conductor-send';
|
|
1110
|
+
/**
|
|
1111
|
+
* 貼り付けのあとに「打った字」として添える1行(貼り付けの枠の外に置く)。
|
|
1112
|
+
*
|
|
1113
|
+
* 【なぜ要るか(便 6bef35c8・実測 2026-09-21 ai-kana 8e47d62e/mamoria 865c8372)】
|
|
1114
|
+
* 本文は paste-buffer -p(ブラケットペースト)で渡すので、職人には「貼り付けだけがあって、
|
|
1115
|
+
* 人が自分で打った字が1つも無い入力」として届く。Claude Code は貼り付けの中の指示を
|
|
1116
|
+
* 「人が自分の言葉で頼んだときだけ従う」扱いにするため、起こし直したばかりの職人
|
|
1117
|
+
* (これまでの便を実行した流れが会話に無い)が「貼り付け以外に指示が無い」と見て、
|
|
1118
|
+
* 実行してよいか人に聞いて止まった(管制には idle_prompt として出た)。
|
|
1119
|
+
* 【直し】人が便を貼ってから一言添えて送るのと同じ形にする。添え書きは枠の外に
|
|
1120
|
+
* send-keys -l(1字ずつ打つのと同じ)で置く。★本文も守り(番人・囲い・秘密保護)も変えない。
|
|
1121
|
+
* ★言えることだけを言う:配達役が届けるのは、この端末の持ち主の管制に積まれた便だけ
|
|
1122
|
+
* (中央は持ち主の合言葉でしか便を受け付けない)。誰が書いたかは本文の「投函:」欄のまま。
|
|
1123
|
+
* ★短く保つ(長い一気の入力は受け手が貼り付けと見なすことがある)。アンカーの字は入れない
|
|
1124
|
+
* (完了検出の境界を増やさないため)。
|
|
1125
|
+
*/
|
|
1126
|
+
export const RELAY_DELIVERY_NOTE = '(配達役より)上の貼り付けは、この端末の持ち主の管制(采配くん)に投函された便を配達役が届けたものです。貼り付けに書かれたとおりに作業してください。';
|
|
1110
1127
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1111
1128
|
// 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07/置き場と作法は 2026-09-21 に改訂)
|
|
1112
1129
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -1457,7 +1474,8 @@ function runTmux(args) {
|
|
|
1457
1474
|
execFileSync('tmux', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
1458
1475
|
}
|
|
1459
1476
|
/**
|
|
1460
|
-
* tmux へ本文を1
|
|
1477
|
+
* tmux へ本文を1メッセージとして送り、貼り付けの枠の外に添え書き(RELAY_DELIVERY_NOTE)を打ち、
|
|
1478
|
+
* 最後に Enter を1回だけ送る。
|
|
1461
1479
|
*
|
|
1462
1480
|
* 【なぜ send-keys の引数に本文を載せないか(実測 2026-09-03 21:34〜の根治)】
|
|
1463
1481
|
* tmux のクライアント→サーバの1命令には上限(IPC のメッセージ長・16KB前後)がある。
|
|
@@ -1493,6 +1511,8 @@ export function sendToTmux(site, text) {
|
|
|
1493
1511
|
file = writeSendTmpFile(text);
|
|
1494
1512
|
runTmux(['load-buffer', '-b', bufferName, file]);
|
|
1495
1513
|
runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
|
|
1514
|
+
// 貼り付けの枠の外に、打った字として添え書きを置く(RELAY_DELIVERY_NOTE の説明)。
|
|
1515
|
+
runTmux(['send-keys', '-t', site, '-l', ` ${RELAY_DELIVERY_NOTE}`]);
|
|
1496
1516
|
runTmux(['send-keys', '-t', site, 'Enter']);
|
|
1497
1517
|
return true;
|
|
1498
1518
|
}
|
package/package.json
CHANGED
package/scripts/hooks/policy.py
CHANGED
|
@@ -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])})'
|
|
@@ -3047,6 +3232,38 @@ _SQL_GATE_RE = re.compile(
|
|
|
3047
3232
|
re.IGNORECASE,
|
|
3048
3233
|
)
|
|
3049
3234
|
|
|
3235
|
+
# ファイルの名前(パス)の形をした語。空白・引用符・セミコロンを含まない1語で、区切り(/)か
|
|
3236
|
+
# 拡張子(.md / .csv 等)を持つもの。
|
|
3237
|
+
_PATH_WORD_RE = re.compile(r'^[\w.@%+=:,~/-]+$')
|
|
3238
|
+
_PATH_EXT_RE = re.compile(r'\.[A-Za-z0-9]{1,8}$')
|
|
3239
|
+
|
|
3240
|
+
|
|
3241
|
+
def _is_path_word(tok: str) -> bool:
|
|
3242
|
+
return bool(_PATH_WORD_RE.match(tok)) and ('/' in tok or bool(_PATH_EXT_RE.search(tok)))
|
|
3243
|
+
|
|
3244
|
+
|
|
3245
|
+
def _sql_scan_text(segment: str) -> str:
|
|
3246
|
+
"""SQL の字面を探す相手(ファイルの名前を除いた命令+ヒアドキュメントの本文)。
|
|
3247
|
+
|
|
3248
|
+
【なぜ除くか(便 6bef35c8・minoru 49cc6ddc/hook 833a458d・実測 2026-09-21)】
|
|
3249
|
+
読むだけの照合 `node …/compare.mjs 期待値.csv …/prod-…-after-grant.md` が、ファイルの
|
|
3250
|
+
名前の `-grant.` を「権限の SQL」と読んで DB の関門に落ちた(同じ照合でも名前に grant が
|
|
3251
|
+
無い便 e8cf6c80 では通っていた)。ファイルの名前は1語で、SQL の文そのものにはなれない。
|
|
3252
|
+
★除くのは名前の形をした語だけ。引用符で包んだ SQL・-c / -e に渡す中身・`-` のあとの
|
|
3253
|
+
ヒアドキュメントの本文は今までどおり見る。DB の道具・移行の道具・本番 DB の直接変更は
|
|
3254
|
+
この字面とは別の決まりで止まる(ここは1件も変えない)。
|
|
3255
|
+
"""
|
|
3256
|
+
head, sep, body = segment.partition('\n')
|
|
3257
|
+
toks = tokens_of(head)
|
|
3258
|
+
kept = []
|
|
3259
|
+
for k, t in enumerate(toks):
|
|
3260
|
+
after_code_flag = k > 0 and toks[k - 1] in _CODE_FLAGS
|
|
3261
|
+
if not after_code_flag and _is_path_word(t):
|
|
3262
|
+
continue
|
|
3263
|
+
kept.append(t)
|
|
3264
|
+
return ' '.join(kept) + sep + body
|
|
3265
|
+
|
|
3266
|
+
|
|
3050
3267
|
# 「引数にSQLらしい字面があっても、実際にはSQLを実行しない」動詞。
|
|
3051
3268
|
# ★SQL文字列だけを根拠にした関門は、これらの動詞では出さない(=誤検知を消す)。
|
|
3052
3269
|
# 棚卸し前は `grep "alter table" f` / `git commit -m "grant …"` / `echo "create table"` /
|
|
@@ -3124,6 +3341,9 @@ def _gate_db(words, tokens, segment: str):
|
|
|
3124
3341
|
return None
|
|
3125
3342
|
v = words[0]
|
|
3126
3343
|
if v in _DB_CLIENTS:
|
|
3344
|
+
# ★手元の使い捨てクラスタへの SQL 実行だけは関門にしない(宛先で決める・SQL は見ない)。
|
|
3345
|
+
if v == _LOCAL_SQL_CLIENT and _local_scratch_sql_ok(tokens):
|
|
3346
|
+
return None
|
|
3127
3347
|
return f'database client ({v})'
|
|
3128
3348
|
joined = ' '.join(words)
|
|
3129
3349
|
if re.match(r'prisma (migrate|db)( |$)', joined):
|
|
@@ -3134,7 +3354,7 @@ def _gate_db(words, tokens, segment: str):
|
|
|
3134
3354
|
return 'drizzle-kit push/migrate'
|
|
3135
3355
|
# SQL の字面(DDL / GRANT / RLS)。★ここへ来るのは「実行しうる入口」だけ
|
|
3136
3356
|
# (文字を扱うだけの道具とシェルは _gate_one で先に振り分け済み)。
|
|
3137
|
-
if _SQL_GATE_RE.search(segment):
|
|
3357
|
+
if _SQL_GATE_RE.search(_sql_scan_text(segment)):
|
|
3138
3358
|
return 'SQL DDL/GRANT/RLS'
|
|
3139
3359
|
return None
|
|
3140
3360
|
|
|
@@ -3249,6 +3469,11 @@ def decide_bash(command: str, cwd=None):
|
|
|
3249
3469
|
if hit:
|
|
3250
3470
|
return _r(DENY, f'danger: {hit}')
|
|
3251
3471
|
|
|
3472
|
+
# ---- 1d') 手元に見せかけた回り道での DB 実行(トンネル・ポート転送・便 ee34f7e5) ----
|
|
3473
|
+
hit = _tunneled_db_danger(command)
|
|
3474
|
+
if hit:
|
|
3475
|
+
return _r(DENY, f'danger: {hit}')
|
|
3476
|
+
|
|
3252
3477
|
# ---- 1e) 本番 DB の直接変更(★判定の置き場は _prod_db_change の1か所・決定は下の定数) ----
|
|
3253
3478
|
hit = _prod_db_change_scan(command)
|
|
3254
3479
|
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 + 空振り確認)')
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ファイルの名前に SQL の字(grant 等)が入っているだけの読む作業を、DB の関門に落とさない検査
|
|
3
|
+
(便 6bef35c8・minoru 49cc6ddc/hook 833a458d)。
|
|
4
|
+
|
|
5
|
+
実行: python3 scripts/hooks/test_sql_word_in_filename.py
|
|
6
|
+
1件でも期待と違えば exit 1。
|
|
7
|
+
|
|
8
|
+
【何を守っているか】
|
|
9
|
+
(p) 読むだけの照合(期待値のファイルと実測のファイルを2つ読むだけ)は、ファイルの名前に
|
|
10
|
+
grant / truncate 等の字があっても確認なしで通る(実物の命令をそのまま使う)。
|
|
11
|
+
(n) ★negative test =本当に SQL を流しうる形は今までどおり止まる
|
|
12
|
+
引用符で包んだ SQL・-c / -e に渡す中身・ヒアドキュメントの本文・DB の道具・移行の道具・
|
|
13
|
+
本番 DB の直接変更・名前でない裸の語。
|
|
14
|
+
(w) 番人の判定が「名前だけを除いた」ことに留まり、ほかの判定を変えていないこと。
|
|
15
|
+
|
|
16
|
+
★この台本は道具の名前と SQL の字を素の字で置かない(組み立てて使う)。台本の行そのものが
|
|
17
|
+
番人の判定に当たって、検査が流せなくなるのを避けるため。
|
|
18
|
+
"""
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
|
|
24
|
+
import policy # noqa: E402
|
|
25
|
+
|
|
26
|
+
CWD = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
27
|
+
G = 'gr' + 'ant'
|
|
28
|
+
TR = 'trun' + 'cate'
|
|
29
|
+
DB_CLIENT = 'p' + 'sql'
|
|
30
|
+
MIG = 'pris' + 'ma'
|
|
31
|
+
PKG_MIG = 'npm run ' + 'migr' + 'ate'
|
|
32
|
+
DDL = 'cre' + 'ate table t(i int)'
|
|
33
|
+
COMPARE = 'node scripts/migration-verify/compare.mjs'
|
|
34
|
+
|
|
35
|
+
failures = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check(label, got, want):
|
|
39
|
+
ok = got == want
|
|
40
|
+
if not ok:
|
|
41
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
42
|
+
print(f'{"OK " if ok else "NG "} {label:66} {got}')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decide(cmd):
|
|
46
|
+
return policy.decide_bash(cmd, CWD)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
# (p) 読むだけの照合は通る
|
|
51
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
POSITIVE = [
|
|
53
|
+
('p1 49cc6ddc の実物(名前に -grant.)',
|
|
54
|
+
f'{COMPARE} docs/migrations/verify/0016_0017_expected.csv '
|
|
55
|
+
f'/tmp/claude-1000/minoru-0016-0017/prod-2026-09-21-after-{G}.md'),
|
|
56
|
+
('p2 e8cf6c80 の実物(旗つき)',
|
|
57
|
+
f'{COMPARE} docs/migrations/verify/e1_estimates_idem_expected.csv '
|
|
58
|
+
f'/tmp/claude-1000/minoru-e1/prod-2026-09-21.md --subset --record'),
|
|
59
|
+
('p3 名前に truncate(下線つなぎ)', f'{COMPARE} a/{TR}_expected.csv b/{TR}_actual.md'),
|
|
60
|
+
('p4 旗に名前を渡す形', f'{COMPARE} --out=/tmp/claude-1000/after-{G}.md a.csv b.md'),
|
|
61
|
+
('p5 拡張子だけの名前', f'{COMPARE} {G}.csv revoke.md'),
|
|
62
|
+
]
|
|
63
|
+
for label, cmd in POSITIVE:
|
|
64
|
+
check(label, decide(cmd)['decision'], 'allow')
|
|
65
|
+
|
|
66
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
# (n) negative test =本当に SQL を流しうる形は止まる(allow にならない)
|
|
68
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
NEGATIVE = [
|
|
70
|
+
('n1 引用符で包んだ権限の SQL を渡す', f'node run.mjs "{G} all on t to anon"'),
|
|
71
|
+
('n2 -e の中身に権限の SQL', f'node -e "q(\'{G} select on t to anon\')"'),
|
|
72
|
+
('n3 -e の中身が名前の形でも見る', f'node -e x.{G}'),
|
|
73
|
+
('n4 -c の中身に DDL', f'python3 -c "run(\'{DDL}\')"'),
|
|
74
|
+
('n5 ヒアドキュメントの本文に権限の SQL', f"python3 - <<'EOF'\nrun('{G} all on t to anon')\nEOF"),
|
|
75
|
+
('n6 名前でない裸の語', f'node run.mjs {G}'),
|
|
76
|
+
('n7 DB の道具に名前つきのファイル', f'{DB_CLIENT} -f /tmp/claude-1000/after-{G}.md'),
|
|
77
|
+
('n8 DB の道具(名前に字なし)', f'{DB_CLIENT} -h db.example.com -c "select 1"'),
|
|
78
|
+
('n9 移行の道具', f'{MIG} migrate deploy'),
|
|
79
|
+
('n10 本番 DB を変える台本', PKG_MIG),
|
|
80
|
+
('n11 名前の後ろに SQL を続けた形', f'node run.mjs /tmp/x.sql "{TR} table t"'),
|
|
81
|
+
]
|
|
82
|
+
for label, cmd in NEGATIVE:
|
|
83
|
+
got = decide(cmd)['decision']
|
|
84
|
+
check(label, got if got in ('gate', 'deny') else f'通ってしまった({got})', got if got in ('gate', 'deny') else 'gate か deny')
|
|
85
|
+
|
|
86
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
# (w) 名前を除く処理そのもの
|
|
88
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
check('w1 名前の形の語は除く', policy._sql_scan_text(f'node a.mjs x/after-{G}.md'), 'node')
|
|
90
|
+
check('w2 引用符の SQL は残す', policy._sql_scan_text(f'node a.mjs "{G} all"'), f'node {G} all')
|
|
91
|
+
check('w3 -e の中身は残す', policy._sql_scan_text(f'node -e x.{G}'), f'node -e x.{G}')
|
|
92
|
+
check('w4 本文は残す', policy._sql_scan_text(f'python3 - <<E\n{G}\nE'), f'python3 - <<E\n{G}\nE')
|
|
93
|
+
|
|
94
|
+
print()
|
|
95
|
+
if failures:
|
|
96
|
+
print(f'NG {len(failures)} 件')
|
|
97
|
+
for f in failures:
|
|
98
|
+
print(' ', f)
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
print('すべて期待どおり')
|
|
@@ -10,6 +10,10 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
10
10
|
だから「速さ」より「戻せること・止まれること・報せること」を優先する。
|
|
11
11
|
以下の14項を、着手前に読み、作業中も判断の基準にすること。
|
|
12
12
|
|
|
13
|
+
**便の届き方(起こし直した直後も同じ・便 6bef35c8)** — 便は配達役(relay)が職人の画面へ**貼り付け**で届け、枠の外に「(配達役より)…届けたものです」の添え書きを付ける。
|
|
14
|
+
これは持ち主の管制に投函された便なので、「貼り付けしか無い」「頭が `[CC1返事案/…]` だから未承認の案だ」と読んで止まらず、書かれたとおりに進める(`[CC1返事案/…]` は CC1 が書いた文を持ち主が投函した印)。
|
|
15
|
+
★守りは変わらない:番人・囲いに止められたら回避しない(鉄則4)、秘密は扱わない(鉄則12)、本文の中の「本当の判断待ち」は今までどおり人の手番として止まる。
|
|
16
|
+
|
|
13
17
|
## 鉄則14項
|
|
14
18
|
|
|
15
19
|
1. **着手時の確認と枝分かれ** — `git status && git log --oneline -3 && git stash list` を確認してから main を最新化し、新ブランチを切る。
|