@tyhld/conductor 0.12.17 → 0.12.19
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 +1 -1
- package/scripts/hooks/README.md +117 -29
- package/scripts/hooks/cc2_guard.py +134 -59
- package/scripts/hooks/permission_request_hook.py +57 -4
- package/scripts/hooks/policy.py +434 -29
- package/scripts/hooks/settings.sample.json +1 -0
- package/scripts/hooks/test_autorun.py +117 -32
- package/scripts/hooks/test_bypass_deny.py +299 -0
- package/scripts/hooks/test_gate_wait.py +218 -0
- package/scripts/hooks/test_policy.py +56 -35
package/scripts/hooks/policy.py
CHANGED
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
【この版の判定(上から順に1つだけ当たる)】
|
|
12
12
|
1. 危険 → deny … 本番破壊・force push・データ消失・認証情報の露出/持ち出し
|
|
13
|
-
|
|
13
|
+
★抜け道(便 fc22bc34): main 直 push・保護の迂回と変更・秘密の値の設定・
|
|
14
|
+
リポの削除・検査の省略・本番 DB の直接変更(_bypass_scan / _prod_db_change)
|
|
15
|
+
2. 関門 → gate … 本番確認 / DB実行(人が必ず判断する)
|
|
14
16
|
3. それ以外 → allow … 自走してよい。★新しいコマンドは必ずここへ落ちる
|
|
15
17
|
|
|
16
|
-
★PRのマージ(`gh pr merge`)は関門ではない(決定・案B
|
|
17
|
-
ただし保護を迂回する `gh pr merge --admin`
|
|
18
|
+
★PRのマージ(`gh pr merge`)は関門ではない(決定・案B完全自動化)。
|
|
19
|
+
ただし保護を迂回する `gh pr merge --admin` と、API での合流(pulls/N/merge)は危険(deny)。
|
|
20
|
+
★main など保護ブランチへの直接 push は、関門から危険(deny)へ移した(えふさん確定 2026-09-18①)。
|
|
21
|
+
通常は PR 経由に統一する。関門の種別の字面 'merge' は管制の表示・集計のために残してある。
|
|
18
22
|
|
|
19
23
|
【危険は「動詞」ではなく「対象と作用」で見る】
|
|
20
24
|
旧番人は動詞を白に入れた瞬間に対象を見なくなり、`curl -d @.env`・`cp .env /tmp`・
|
|
@@ -38,9 +42,10 @@ import shlex
|
|
|
38
42
|
DENY, GATE, ALLOW = 'deny', 'gate', 'allow'
|
|
39
43
|
|
|
40
44
|
# 関門の種類(表示・集計用)。
|
|
41
|
-
# ★GATE_MERGE
|
|
42
|
-
#
|
|
43
|
-
|
|
45
|
+
# ★GATE_MERGE は「PRのマージ」→「main への直接 push」→(便 fc22bc34)関門でなくなった
|
|
46
|
+
# (main 直 push は deny・_main_push_danger)。種別の字面 'merge' は管制の表示・集計が
|
|
47
|
+
# 使っているので、定数は残す(policy からはもう返さない)。
|
|
48
|
+
GATE_MERGE = 'merge' # 旧 関門1 本番ブランチの直接変更(いまは deny)
|
|
44
49
|
GATE_RELEASE = 'release' # 関門2 本番確認
|
|
45
50
|
GATE_DB = 'db' # 関門3 DB実行
|
|
46
51
|
|
|
@@ -706,6 +711,10 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
|
|
|
706
711
|
or _recursive_read_danger(v, toks, cwd) or _admin_merge_danger(v, toks))
|
|
707
712
|
if hit:
|
|
708
713
|
return f'{hit} ({where})'
|
|
714
|
+
# ★抜け道も包みの中で測り直す(`bash -c "git push origin main"` を素通りさせない)。
|
|
715
|
+
hit = _bypass_scan(inner, cwd)
|
|
716
|
+
if hit:
|
|
717
|
+
return f'{hit} ({where})'
|
|
709
718
|
hit = _wrapper_danger(inner, depth + 1, cwd)
|
|
710
719
|
if hit:
|
|
711
720
|
return hit
|
|
@@ -1068,6 +1077,411 @@ def _admin_merge_danger(verb: str, tokens):
|
|
|
1068
1077
|
return None
|
|
1069
1078
|
|
|
1070
1079
|
|
|
1080
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1081
|
+
# 抜け道(DENY・便 fc22bc34・えふさん確定 2026-09-18)
|
|
1082
|
+
#
|
|
1083
|
+
# 【考え方】ALLOW=普通の開発・練習環境/ASK=正当だが実世界に影響する操作/
|
|
1084
|
+
# DENY=職人が行う必要が無い抜け道(main 直 push・本番 DB の直接変更・秘密の値の操作・
|
|
1085
|
+
# 保護の変更・検査の省略・迂回による保護回避)。ここは DENY の側。
|
|
1086
|
+
# 【字面だけでは止めない】どれも「実際に動く道具の語の並び」で見る。コミットの説明や PR の本文に
|
|
1087
|
+
# 同じ語が書かれているだけの操作は止めない(動詞と、その後ろの語の並びで決める)。
|
|
1088
|
+
# ★例外は [skip ci] 類: コミットの説明に書かれた時点で GitHub が CI を飛ばす(本物の省略)。
|
|
1089
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1090
|
+
|
|
1091
|
+
# npx / pnpm dlx などの「道具を呼ぶための前置き」。取り除いて道具の語の並びを見る。
|
|
1092
|
+
_TOOL_RUNNERS = frozenset({'npx', 'pnpx', 'bunx'})
|
|
1093
|
+
_PKG_MANAGERS = frozenset({'npm', 'pnpm', 'yarn'})
|
|
1094
|
+
_PKG_EXEC_WORDS = frozenset({'exec', 'dlx', 'x'})
|
|
1095
|
+
# 前置きの後ろに直接書かれる道具(`pnpm vercel env add` の形)。
|
|
1096
|
+
_KNOWN_TOOLS = frozenset({
|
|
1097
|
+
'vercel', 'supabase', 'wrangler', 'prisma', 'drizzle-kit', 'fly', 'flyctl', 'heroku',
|
|
1098
|
+
'netlify', 'gh', 'psql', 'dropdb', 'claude',
|
|
1099
|
+
})
|
|
1100
|
+
# 値を1つ取る旗(語の並びから値ごと外す)。
|
|
1101
|
+
_VALUE_FLAGS = frozenset({
|
|
1102
|
+
'--scope', '-S', '--token', '-t', '--cwd', '--project-ref', '--app', '-a', '--config',
|
|
1103
|
+
'-c', '-C', '--account-id', '--site', '--env', '-e', '--repo', '-R', '--schema',
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
# 作業場所が変数などで決まり、確かめられないことを表す印(None=「指定なし=今の場所」とは別)。
|
|
1107
|
+
_UNKNOWN_DIR = object()
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _tool_words(tokens):
|
|
1111
|
+
"""道具の語の並び(前置き・変数代入・旗を外し、小文字にしたもの)。無ければ []。"""
|
|
1112
|
+
i = 0
|
|
1113
|
+
while i < len(tokens):
|
|
1114
|
+
t = tokens[i]
|
|
1115
|
+
if _ASSIGN_RE.match(t):
|
|
1116
|
+
i += 1
|
|
1117
|
+
continue
|
|
1118
|
+
base = t.split('/')[-1]
|
|
1119
|
+
if base in _WRAPPERS:
|
|
1120
|
+
i += 1
|
|
1121
|
+
while i < len(tokens) and tokens[i].startswith('-'):
|
|
1122
|
+
i += 1
|
|
1123
|
+
if base in ('timeout', 'nice', 'ionice') and i < len(tokens) \
|
|
1124
|
+
and not tokens[i].startswith('-'):
|
|
1125
|
+
i += 1
|
|
1126
|
+
continue
|
|
1127
|
+
break
|
|
1128
|
+
rest = tokens[i:]
|
|
1129
|
+
if not rest:
|
|
1130
|
+
return []
|
|
1131
|
+
head = rest[0].split('/')[-1].lower()
|
|
1132
|
+
if head in _TOOL_RUNNERS or head in _PKG_MANAGERS:
|
|
1133
|
+
j = 1
|
|
1134
|
+
while j < len(rest) and (rest[j].startswith('-') or rest[j].lower() in _PKG_EXEC_WORDS):
|
|
1135
|
+
j += 1
|
|
1136
|
+
nxt = rest[j].split('/')[-1].lower() if j < len(rest) else ''
|
|
1137
|
+
if head in _TOOL_RUNNERS or rest[1:2] and rest[1].lower() in _PKG_EXEC_WORDS \
|
|
1138
|
+
or nxt in _KNOWN_TOOLS:
|
|
1139
|
+
rest = rest[j:]
|
|
1140
|
+
words = []
|
|
1141
|
+
skip = False
|
|
1142
|
+
for k, t in enumerate(rest):
|
|
1143
|
+
if skip:
|
|
1144
|
+
skip = False
|
|
1145
|
+
continue
|
|
1146
|
+
if k > 0 and t.startswith('-'):
|
|
1147
|
+
if t in _VALUE_FLAGS:
|
|
1148
|
+
skip = True
|
|
1149
|
+
continue
|
|
1150
|
+
words.append(t.split('/')[-1].lower() if k == 0 else t.lower())
|
|
1151
|
+
return words
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _current_branch_targets(cwd):
|
|
1155
|
+
"""cwd のリポで、引数なしの push が書き込む先のブランチ名の集合。確かめられなければ None。
|
|
1156
|
+
|
|
1157
|
+
★今のブランチ(rev-parse --abbrev-ref HEAD)と、設定された push 先(@{push})の両方を見る。
|
|
1158
|
+
切り離された HEAD・git が無い・遅い(5秒)・cwd が分からない、は確かめられない=None。
|
|
1159
|
+
"""
|
|
1160
|
+
try:
|
|
1161
|
+
import subprocess
|
|
1162
|
+
def out(args):
|
|
1163
|
+
r = subprocess.run(['git'] + args, cwd=cwd or None, capture_output=True,
|
|
1164
|
+
text=True, timeout=5)
|
|
1165
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
1166
|
+
head = out(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
1167
|
+
if not head or head == 'HEAD':
|
|
1168
|
+
return None
|
|
1169
|
+
targets = {head}
|
|
1170
|
+
push = out(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{push}'])
|
|
1171
|
+
if push and '/' in push:
|
|
1172
|
+
targets.add(push.split('/', 1)[1])
|
|
1173
|
+
return targets
|
|
1174
|
+
except Exception: # noqa: BLE001
|
|
1175
|
+
return None
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _main_push_danger(tokens, cwd):
|
|
1179
|
+
"""main など保護ブランチへの直接 push(★便 fc22bc34 で関門から DENY へ・えふさん確定①)。
|
|
1180
|
+
|
|
1181
|
+
★通常は PR 経由に統一する。職人が main へ直接 push する必要は無い。
|
|
1182
|
+
★書き込む先を書かない形(`git push` / `git push origin` / `git push origin HEAD`)は、
|
|
1183
|
+
今のブランチを git で確かめる。確かめられないとき(切り離された HEAD・git が答えない・
|
|
1184
|
+
作業場所が変数で決まる)は DENY に倒す=main へ書く可能性を消せないものは通さない。
|
|
1185
|
+
★`--all` / `--mirror` / `--branches` は全部のブランチを書くので DENY。
|
|
1186
|
+
"""
|
|
1187
|
+
words = _tool_words(tokens)
|
|
1188
|
+
if not words or words[0] != 'git':
|
|
1189
|
+
return None
|
|
1190
|
+
try:
|
|
1191
|
+
start = [t.split('/')[-1] for t in tokens].index('git')
|
|
1192
|
+
except ValueError:
|
|
1193
|
+
return None
|
|
1194
|
+
# git の前置きの旗(-C 場所 / -c 設定)を読む。
|
|
1195
|
+
where = cwd
|
|
1196
|
+
args = list(tokens[start + 1:])
|
|
1197
|
+
while args and args[0].startswith('-'):
|
|
1198
|
+
flag = args.pop(0)
|
|
1199
|
+
if flag == '-C' and args:
|
|
1200
|
+
p = args.pop(0)
|
|
1201
|
+
if any(c in p for c in '$`') or where is _UNKNOWN_DIR:
|
|
1202
|
+
where = _UNKNOWN_DIR
|
|
1203
|
+
else:
|
|
1204
|
+
p = os.path.expanduser(p)
|
|
1205
|
+
where = p if os.path.isabs(p) else os.path.join(where or os.getcwd(), p)
|
|
1206
|
+
elif flag == '-c' and args:
|
|
1207
|
+
args.pop(0)
|
|
1208
|
+
if not args or args[0] != 'push':
|
|
1209
|
+
return None
|
|
1210
|
+
push_args = args[1:]
|
|
1211
|
+
positional = []
|
|
1212
|
+
it = iter(push_args)
|
|
1213
|
+
for t in it:
|
|
1214
|
+
if t in ('--all', '--mirror', '--branches'):
|
|
1215
|
+
return f'git push {t} (全部のブランチ=保護ブランチも書く)'
|
|
1216
|
+
if t in ('--repo', '-o', '--push-option', '--receive-pack', '--exec'):
|
|
1217
|
+
next(it, None)
|
|
1218
|
+
continue
|
|
1219
|
+
if t.startswith('-'):
|
|
1220
|
+
continue
|
|
1221
|
+
positional.append(t)
|
|
1222
|
+
refspecs = positional[1:]
|
|
1223
|
+
needs_head = not refspecs
|
|
1224
|
+
for spec in refspecs:
|
|
1225
|
+
spec = spec.lstrip('+')
|
|
1226
|
+
src, _, dst = spec.partition(':')
|
|
1227
|
+
target = dst if dst else src
|
|
1228
|
+
if target in ('HEAD', '@'):
|
|
1229
|
+
needs_head = True
|
|
1230
|
+
continue
|
|
1231
|
+
name = target.removeprefix('refs/heads/')
|
|
1232
|
+
if name in PROTECTED_BRANCHES:
|
|
1233
|
+
return f'git push to protected branch ({name})'
|
|
1234
|
+
if needs_head:
|
|
1235
|
+
if where is _UNKNOWN_DIR:
|
|
1236
|
+
return 'git push (書き込む先のブランチを確かめられない=作業場所が変数)'
|
|
1237
|
+
targets = _current_branch_targets(where)
|
|
1238
|
+
if targets is None:
|
|
1239
|
+
return 'git push (書き込む先のブランチを確かめられない)'
|
|
1240
|
+
hit = sorted(targets & PROTECTED_BRANCHES)
|
|
1241
|
+
if hit:
|
|
1242
|
+
return f'git push to protected branch ({hit[0]}・今のブランチ)'
|
|
1243
|
+
return None
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
# GitHub の API の道筋(書き込み)。
|
|
1247
|
+
_API_MERGE_RE = re.compile(r'(^|/)pulls/[^/]+/merge/?$')
|
|
1248
|
+
_API_PROTECTION_RE = re.compile(
|
|
1249
|
+
r'/branches/[^/]+/protection(/|$)|(^|/)rulesets(/|$)|/environments/[^/]+/?$'
|
|
1250
|
+
r'|/deployment-branch-policies(/|$)|/deployment_protection_rules(/|$)')
|
|
1251
|
+
_API_SECRET_RE = re.compile(
|
|
1252
|
+
r'/(actions|dependabot|codespaces|agents)/(secrets|variables)(/|$)'
|
|
1253
|
+
r'|/environments/[^/]+/(secrets|variables)(/|$)')
|
|
1254
|
+
_API_REPO_RE = re.compile(r'^repos/[^/]+/[^/]+/?$')
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _github_api_write(endpoint: str, method: str, body_low: str):
|
|
1258
|
+
"""GitHub の API への書き込み1件が抜け道なら理由を返す(読み取りは通す)。"""
|
|
1259
|
+
ep = re.sub(r'^https?://api\.github\.com/', '', endpoint.strip()).lstrip('/').split('?')[0]
|
|
1260
|
+
if method in ('GET', 'HEAD'):
|
|
1261
|
+
return None
|
|
1262
|
+
if _API_MERGE_RE.search(ep) and method in ('PUT', 'POST'):
|
|
1263
|
+
return 'API での合流 (pulls/N/merge・保護を迂回する合流)'
|
|
1264
|
+
if ep.rstrip('/') == 'graphql' and 'mergepullrequest' in body_low:
|
|
1265
|
+
return 'GraphQL での合流 (mergePullRequest・保護を迂回する合流)'
|
|
1266
|
+
if _API_PROTECTION_RE.search(ep):
|
|
1267
|
+
return f'保護の変更 ({method} {ep})'
|
|
1268
|
+
if _API_SECRET_RE.search(ep):
|
|
1269
|
+
return f'秘密の値・変数の変更 ({method} {ep})'
|
|
1270
|
+
if _API_REPO_RE.match(ep) and method == 'DELETE':
|
|
1271
|
+
return 'リポの削除 (API)'
|
|
1272
|
+
return None
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
_GH_API_VALUE_FLAGS = frozenset({
|
|
1276
|
+
'-X', '--method', '-H', '--header', '-f', '--raw-field', '-F', '--field', '--input',
|
|
1277
|
+
'-q', '--jq', '-t', '--template', '--hostname', '-p', '--preview', '--cache',
|
|
1278
|
+
})
|
|
1279
|
+
_GH_API_BODY_FLAGS = frozenset({'-f', '--raw-field', '-F', '--field', '--input'})
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _gh_api_danger(words, tokens, seg_low):
|
|
1283
|
+
if words[:2] != ['gh', 'api']:
|
|
1284
|
+
return None
|
|
1285
|
+
try:
|
|
1286
|
+
start = [t.split('/')[-1].lower() for t in tokens].index('gh')
|
|
1287
|
+
except ValueError:
|
|
1288
|
+
return None
|
|
1289
|
+
args = tokens[start + 2:]
|
|
1290
|
+
method, endpoint, has_body = None, None, False
|
|
1291
|
+
k = 0
|
|
1292
|
+
while k < len(args):
|
|
1293
|
+
t = args[k]
|
|
1294
|
+
if t.startswith('-X') and len(t) > 2:
|
|
1295
|
+
method = t[2:]
|
|
1296
|
+
elif t.startswith('--method='):
|
|
1297
|
+
method = t.split('=', 1)[1]
|
|
1298
|
+
elif t in _GH_API_VALUE_FLAGS:
|
|
1299
|
+
if t in ('-X', '--method') and k + 1 < len(args):
|
|
1300
|
+
method = args[k + 1]
|
|
1301
|
+
if t in _GH_API_BODY_FLAGS:
|
|
1302
|
+
has_body = True
|
|
1303
|
+
k += 2
|
|
1304
|
+
continue
|
|
1305
|
+
elif any(t.startswith(f + '=') for f in ('--raw-field', '--field', '--input')):
|
|
1306
|
+
has_body = True
|
|
1307
|
+
elif not t.startswith('-') and endpoint is None:
|
|
1308
|
+
endpoint = t
|
|
1309
|
+
k += 1
|
|
1310
|
+
if endpoint is None:
|
|
1311
|
+
return None
|
|
1312
|
+
method = (method or ('POST' if has_body else 'GET')).upper()
|
|
1313
|
+
return _github_api_write(endpoint, method, seg_low)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
_HTTP_VERBS = frozenset({'curl', 'wget', 'http', 'https', 'xh'})
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _http_github_danger(words, tokens, seg_low):
|
|
1320
|
+
"""curl などで GitHub の API へ直接書き込む形(gh を通さない抜け道)。"""
|
|
1321
|
+
if not words or words[0] not in _HTTP_VERBS:
|
|
1322
|
+
return None
|
|
1323
|
+
urls = [t for t in tokens if 'api.github.com/' in t.lower()]
|
|
1324
|
+
if not urls:
|
|
1325
|
+
return None
|
|
1326
|
+
method = None
|
|
1327
|
+
for k, t in enumerate(tokens):
|
|
1328
|
+
if t in ('-X', '--request', '--method') and k + 1 < len(tokens):
|
|
1329
|
+
method = tokens[k + 1]
|
|
1330
|
+
elif t.startswith('-X') and len(t) > 2:
|
|
1331
|
+
method = t[2:]
|
|
1332
|
+
elif t.startswith('--method='):
|
|
1333
|
+
method = t.split('=', 1)[1]
|
|
1334
|
+
if method is None:
|
|
1335
|
+
has_body = any(t in ('-d', '--data', '--data-raw', '--data-binary', '--json', '-F', '--form')
|
|
1336
|
+
or t.startswith('--data') for t in tokens)
|
|
1337
|
+
method = 'POST' if has_body else 'GET'
|
|
1338
|
+
if words[0] in ('http', 'https', 'xh') and len(words) > 1 \
|
|
1339
|
+
and words[1].upper() in ('PUT', 'POST', 'PATCH', 'DELETE'):
|
|
1340
|
+
method = words[1]
|
|
1341
|
+
for u in urls:
|
|
1342
|
+
hit = _github_api_write(u.split('api.github.com/', 1)[1], method.upper(), seg_low)
|
|
1343
|
+
if hit:
|
|
1344
|
+
return hit
|
|
1345
|
+
return None
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
_SECRET_SET_RE = re.compile(
|
|
1349
|
+
r'^(vercel env (add|rm|remove|update)'
|
|
1350
|
+
r'|supabase secrets (set|unset)'
|
|
1351
|
+
r'|wrangler (versions )?secret (put|delete|bulk)'
|
|
1352
|
+
r'|gh variable (set|delete)'
|
|
1353
|
+
r'|(fly|flyctl) secrets (set|unset|import)'
|
|
1354
|
+
r'|heroku config:(set|unset)'
|
|
1355
|
+
r'|netlify env:(set|unset|import))( |$)')
|
|
1356
|
+
|
|
1357
|
+
# GitHub が「CI を飛ばす」と読む書き方(コミットの説明に入った時点で効く)。
|
|
1358
|
+
_SKIP_CI_RE = re.compile(
|
|
1359
|
+
r'\[\s*(skip ci|ci skip|no ci|skip actions|actions skip)\s*\]|skip-checks\s*:\s*true',
|
|
1360
|
+
re.IGNORECASE)
|
|
1361
|
+
|
|
1362
|
+
_SKIP_PERMISSION_FLAGS = frozenset({
|
|
1363
|
+
'--dangerously-skip-permissions', '--allow-dangerously-skip-permissions',
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _bypass_segment_danger(seg, tokens, cwd, command):
|
|
1368
|
+
"""1セグメントの抜け道(main 直 push 以外)。"""
|
|
1369
|
+
words = _tool_words(tokens)
|
|
1370
|
+
if not words:
|
|
1371
|
+
return None
|
|
1372
|
+
seg_low = seg.lower()
|
|
1373
|
+
hit = _gh_api_danger(words, tokens, seg_low) or _http_github_danger(words, tokens, seg_low)
|
|
1374
|
+
if hit:
|
|
1375
|
+
return hit
|
|
1376
|
+
joined = ' '.join(words)
|
|
1377
|
+
if _SECRET_SET_RE.match(joined):
|
|
1378
|
+
return f'秘密の値の設定・削除 ({" ".join(words[:3])})'
|
|
1379
|
+
if words[:3] == ['gh', 'repo', 'delete']:
|
|
1380
|
+
return 'リポの削除 (gh repo delete)'
|
|
1381
|
+
if words[0] == 'git':
|
|
1382
|
+
if '--no-verify' in tokens:
|
|
1383
|
+
return 'git --no-verify (手元の検査の省略)'
|
|
1384
|
+
sub = next((w for w in words[1:] if w != 'git'), '')
|
|
1385
|
+
if sub == 'commit' and '-n' in tokens:
|
|
1386
|
+
return 'git commit -n (手元の検査の省略)'
|
|
1387
|
+
if any(t.lower().startswith('core.hookspath=') for t in tokens):
|
|
1388
|
+
return 'git -c core.hooksPath (手元の検査の差し替え)'
|
|
1389
|
+
if sub == 'commit' and _SKIP_CI_RE.search(command):
|
|
1390
|
+
return 'コミットの説明に [skip ci] 類 (CI の省略)'
|
|
1391
|
+
if words[:3] == ['gh', 'pr', 'merge'] and _SKIP_CI_RE.search(command):
|
|
1392
|
+
return '合流の説明に [skip ci] 類 (CI の省略)'
|
|
1393
|
+
if words[0] in ('claude', 'claude-code'):
|
|
1394
|
+
if any(t in _SKIP_PERMISSION_FLAGS for t in tokens):
|
|
1395
|
+
return 'claude --dangerously-skip-permissions (安全設定の省略)'
|
|
1396
|
+
for k, t in enumerate(tokens):
|
|
1397
|
+
v = t.split('=', 1)[1] if t.startswith('--permission-mode=') else (
|
|
1398
|
+
tokens[k + 1] if t == '--permission-mode' and k + 1 < len(tokens) else None)
|
|
1399
|
+
if v and v.lower() == 'bypasspermissions':
|
|
1400
|
+
return 'claude --permission-mode bypassPermissions (安全設定の省略)'
|
|
1401
|
+
return None
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _bypass_scan(command: str, cwd=None):
|
|
1405
|
+
"""コマンド全体の抜け道を探す。★`cd 場所` を追って、後ろの push の作業場所に使う。"""
|
|
1406
|
+
here = cwd
|
|
1407
|
+
for seg in split_segments(command):
|
|
1408
|
+
toks = tokens_of(seg)
|
|
1409
|
+
v = verb_of(toks)
|
|
1410
|
+
if v == 'cd':
|
|
1411
|
+
after = toks[toks.index('cd') + 1:] if 'cd' in toks else []
|
|
1412
|
+
dest = next((t for t in after if t != '--'), '~')
|
|
1413
|
+
p = os.path.expanduser(dest)
|
|
1414
|
+
if dest == '-' or any(c in dest for c in '$`*?') or here is _UNKNOWN_DIR:
|
|
1415
|
+
here = _UNKNOWN_DIR
|
|
1416
|
+
elif os.path.isabs(p):
|
|
1417
|
+
here = p
|
|
1418
|
+
else:
|
|
1419
|
+
here = os.path.join(here or os.getcwd(), p)
|
|
1420
|
+
continue
|
|
1421
|
+
hit = _bypass_segment_danger(seg, toks, here, command) or _main_push_danger(toks, here)
|
|
1422
|
+
if hit:
|
|
1423
|
+
return hit
|
|
1424
|
+
return None
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1428
|
+
# 本番 DB の直接変更(★えふさん確定② 2026-09-18)
|
|
1429
|
+
#
|
|
1430
|
+
# 【いまは DENY】判定は _prod_db_change の1か所、決定は PROD_DB_CHANGE_DECISION の1か所。
|
|
1431
|
+
# 対象は、今の設定の deny(ホーム+全20現場に共通)と同じ道具に揃えた。
|
|
1432
|
+
# 【あとで ASK へ移すとき】「職人が本番の鍵を持たない+人が承認した専用の経路」が完成したら、
|
|
1433
|
+
# PROD_DB_CHANGE_DECISION を GATE へ差し替える(1行)。判定の中身はそのまま使える。
|
|
1434
|
+
# GATE にすると関門 db(管制の2択)として返る(試験 test_policy の (p2) で固定)。
|
|
1435
|
+
# ★差し替えるときは、設定の deny(ホーム・各現場)も人の手番で外す。外さないと、番人が
|
|
1436
|
+
# 「はい」を受けても設定の deny で止まる(便 df4accf8 の重大発見2)。
|
|
1437
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1438
|
+
PROD_DB_CHANGE_DECISION = DENY
|
|
1439
|
+
|
|
1440
|
+
# ★頭の語は道具の名前と【完全に一致】するときだけ当てる(`psql"` のような文字列の切れ端を拾わない)。
|
|
1441
|
+
_PROD_DB_CHANGE_RE = re.compile(
|
|
1442
|
+
r'^(psql|dropdb)( |$)'
|
|
1443
|
+
r'|^prisma (db push|migrate (deploy|reset))( |$)'
|
|
1444
|
+
r'|^supabase (db push|migration up)( |$)'
|
|
1445
|
+
r'|^drizzle-kit push( |$)')
|
|
1446
|
+
# npm / pnpm / yarn 自身の台本の実行(`npm run migrate` / `pnpm migrate:prod`)。
|
|
1447
|
+
_PKG_MIGRATE_RE = re.compile(r'^(npm|pnpm|yarn) ((run|run-script) )?migrate')
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _prod_db_change(seg, tokens):
|
|
1451
|
+
"""1セグメントが本番 DB を直接変える道具なら理由を返す。"""
|
|
1452
|
+
words = _tool_words(tokens)
|
|
1453
|
+
if not words:
|
|
1454
|
+
return None
|
|
1455
|
+
joined = ' '.join(words)
|
|
1456
|
+
if _PROD_DB_CHANGE_RE.match(joined):
|
|
1457
|
+
return f'本番 DB の直接変更 ({" ".join(words[:3])})'
|
|
1458
|
+
if _PKG_MIGRATE_RE.match(joined):
|
|
1459
|
+
return f'本番 DB の直接変更 ({" ".join(words[:3])})'
|
|
1460
|
+
if words[0] in _HTTP_VERBS and 'rest/v1/rpc/exec_sql' in seg.lower():
|
|
1461
|
+
return '本番 DB の直接変更 (REST の exec_sql)'
|
|
1462
|
+
return None
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _prod_db_change_scan(command: str, depth: int = 0):
|
|
1466
|
+
for seg in split_segments(command):
|
|
1467
|
+
hit = _prod_db_change(seg, tokens_of(seg))
|
|
1468
|
+
if hit:
|
|
1469
|
+
return hit
|
|
1470
|
+
if depth < _UNWRAP_MAX_DEPTH:
|
|
1471
|
+
for kind, runner, inner in _inner_payloads(command):
|
|
1472
|
+
if kind == 'shell' and inner and inner.strip():
|
|
1473
|
+
hit = _prod_db_change_scan(inner, depth + 1)
|
|
1474
|
+
if hit:
|
|
1475
|
+
return f'{hit} (inside {runner})'
|
|
1476
|
+
return None
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _prod_db_change_result(reason):
|
|
1480
|
+
if PROD_DB_CHANGE_DECISION == GATE:
|
|
1481
|
+
return _r(GATE, f'gate(db): {reason}', GATE_DB)
|
|
1482
|
+
return _r(DENY, f'danger: {reason}')
|
|
1483
|
+
|
|
1484
|
+
|
|
1071
1485
|
# 危険でない作業フォルダの絶対パス先頭成分(/tmp/xxx は限定削除として通す)。
|
|
1072
1486
|
_SAFE_ABS_TOP = frozenset({'tmp'})
|
|
1073
1487
|
|
|
@@ -1196,26 +1610,6 @@ _SQL_TEXT_ONLY_VERBS = frozenset({
|
|
|
1196
1610
|
})
|
|
1197
1611
|
|
|
1198
1612
|
|
|
1199
|
-
def _gate_merge(low: str, verb: str, tokens):
|
|
1200
|
-
"""関門1: 本番ブランチ(main 等)を【PRを通さずに】直接書き換える操作。
|
|
1201
|
-
|
|
1202
|
-
★PRのマージ(`gh pr merge` / `gh api …/pulls/N/merge`)は関門から外した(案B完全自動化)。
|
|
1203
|
-
理由: マージを関門にすると、職人は最後の1手で必ず人を待つことになり、
|
|
1204
|
-
「指示を出したら終わりまで進む」が成立しなかった。マージはPRという記録が残り、
|
|
1205
|
-
レビューとCIの結果が付いた上での操作なので、人が毎回押す価値より止まる損の方が大きい。
|
|
1206
|
-
★外したのはマージだけ。main への【直接 push】は関門のまま残す。こちらはPRもレビューも
|
|
1207
|
-
CIも通らずに本番の元が変わるため、性質がまったく違う(外すと歯止めが無くなる)。
|
|
1208
|
-
"""
|
|
1209
|
-
if verb == 'git' and len(tokens) >= 2 and tokens[1] == 'push':
|
|
1210
|
-
for t in tokens[2:]:
|
|
1211
|
-
if t.startswith('-'):
|
|
1212
|
-
continue
|
|
1213
|
-
ref = t.split(':')[-1].strip()
|
|
1214
|
-
if ref in PROTECTED_BRANCHES:
|
|
1215
|
-
return f'git push to protected branch ({ref})'
|
|
1216
|
-
return None
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
1613
|
def _gate_release(low: str, verb: str, tokens):
|
|
1220
1614
|
if verb == 'vercel' or (verb == 'npx' and 'vercel' in tokens[:3]):
|
|
1221
1615
|
if '--prod' in tokens or '--production' in tokens:
|
|
@@ -1294,14 +1688,21 @@ def decide_bash(command: str, cwd=None):
|
|
|
1294
1688
|
if hit:
|
|
1295
1689
|
return _r(DENY, f'danger: {hit}')
|
|
1296
1690
|
|
|
1691
|
+
# ---- 1d) 抜け道(便 fc22bc34・main 直 push・保護の迂回と変更・秘密の値・検査の省略) ----
|
|
1692
|
+
hit = _bypass_scan(command, cwd)
|
|
1693
|
+
if hit:
|
|
1694
|
+
return _r(DENY, f'danger: {hit}')
|
|
1695
|
+
|
|
1696
|
+
# ---- 1e) 本番 DB の直接変更(★判定の置き場は _prod_db_change の1か所・決定は下の定数) ----
|
|
1697
|
+
hit = _prod_db_change_scan(command)
|
|
1698
|
+
if hit:
|
|
1699
|
+
return _prod_db_change_result(hit)
|
|
1700
|
+
|
|
1297
1701
|
# ---- 2) 3関門 ----
|
|
1298
1702
|
for seg in segments:
|
|
1299
1703
|
toks = tokens_of(seg)
|
|
1300
1704
|
v = verb_of(toks)
|
|
1301
1705
|
seg_low = seg.lower()
|
|
1302
|
-
hit = _gate_merge(seg_low, v, toks)
|
|
1303
|
-
if hit:
|
|
1304
|
-
return _r(GATE, f'gate(merge): {hit}', GATE_MERGE)
|
|
1305
1706
|
hit = _gate_release(seg_low, v, toks)
|
|
1306
1707
|
if hit:
|
|
1307
1708
|
return _r(GATE, f'gate(release): {hit}', GATE_RELEASE)
|
|
@@ -1550,6 +1951,10 @@ def decide_event(data):
|
|
|
1550
1951
|
return decide_read(fp, data.get('cwd'))
|
|
1551
1952
|
if tool in SEARCH_TOOLS:
|
|
1552
1953
|
return decide_search(tin, data.get('cwd'), tool)
|
|
1954
|
+
# ★保護の変更(便 fc22bc34): つなぎ先の道具で本番のデプロイの保護を変える形。
|
|
1955
|
+
# 強める向きも含めて DENY(字面では強める・弱めるを見分けられない)。
|
|
1956
|
+
if isinstance(tool, str) and re.search(r'(^|__)update_project_deployment_protection$', tool):
|
|
1957
|
+
return _r(DENY, 'danger: 保護の変更 (デプロイの保護・つなぎ先の道具)')
|
|
1553
1958
|
# ★知らない道具(MCP・将来増える組み込み)も、入力にパスらしい項目があれば同じ判定を通す。
|
|
1554
1959
|
# 当たらなければ従来どおり自走する(WebSearch・道具の呼び出し等は1つも止まらない)。
|
|
1555
1960
|
return decide_any_path(tin, data.get('cwd'))
|