@tyhld/conductor 0.12.19 → 0.12.21
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/scripts/hooks/policy.py
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import os
|
|
33
33
|
import re
|
|
34
34
|
import shlex
|
|
35
|
+
import urllib.parse
|
|
35
36
|
|
|
36
37
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
37
38
|
# 判定の返り値
|
|
@@ -476,10 +477,115 @@ _SPLIT_RE = re.compile(r'&&|\|\||[;|&\n]')
|
|
|
476
477
|
|
|
477
478
|
|
|
478
479
|
def split_segments(command: str):
|
|
479
|
-
"""複合コマンドを単純コマンドへ割る(判定は各セグメントに対して行う)。
|
|
480
|
+
"""複合コマンドを単純コマンドへ割る(判定は各セグメントに対して行う)。
|
|
481
|
+
|
|
482
|
+
★機械的に割る(引用符・ヒアドキュメントの中でも割る)。危険(deny)の判定はこちらを使う
|
|
483
|
+
=割り方を変えても止める側は1件も減らない(便 ad6b5db9)。関門は split_commands を使う。
|
|
484
|
+
"""
|
|
480
485
|
return [s.strip() for s in _SPLIT_RE.split(command) if s.strip()]
|
|
481
486
|
|
|
482
487
|
|
|
488
|
+
# ヒアドキュメントの始まり(<<EOF / <<-EOF / <<'EOF' / <<"EOF")。<<< は文字列を渡す別物。
|
|
489
|
+
_HEREDOC_START_RE = re.compile(r'<<(-?)[ \t]*([\'"]?)([A-Za-z_][A-Za-z0-9_]*)\2')
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def split_commands(command: str, bodies: bool = True):
|
|
493
|
+
"""★本物の区切りだけで割る(便 ad6b5db9・関門の判定に使う)。
|
|
494
|
+
|
|
495
|
+
【なぜ要るか(便 df4accf8 の重大発見1・便 30879701)】split_segments は引用符や
|
|
496
|
+
ヒアドキュメントの中でも割るので、コミットの説明・PR の本文・検索の文字・手順書の中の
|
|
497
|
+
「create table」「vercel --prod」が、ひとつのコマンドとして関門に当たっていた(7日で90件)。
|
|
498
|
+
【どう割るか】
|
|
499
|
+
・引用符(' " `)の中は割らない。\\ の後ろの1文字もそのまま。
|
|
500
|
+
・ヒアドキュメントの本文は、行の終わりで【その時点の最後のコマンド】に付けてひとかたまりにする
|
|
501
|
+
(`cat <<EOF > f` なら cat に、`cat <<EOF | psql` なら流れ込む先の psql に付く)。
|
|
502
|
+
・# から行末までの注釈は捨てる(語の頭にあるときだけ)。
|
|
503
|
+
・2>&1 / &> / >& の & は区切りではない。
|
|
504
|
+
・bodies=False のときは、ヒアドキュメントの本文を付けずに読み飛ばす(便 2.5・止める側の
|
|
505
|
+
読み直しに使う。本文の文字をコマンドの引数と取り違えないため)。
|
|
506
|
+
"""
|
|
507
|
+
out, cur = [], []
|
|
508
|
+
pending = [] # 本文を待っているヒアドキュメント [(区切りの語, 字下げを許すか)]
|
|
509
|
+
quote = None
|
|
510
|
+
i, n = 0, len(command or '')
|
|
511
|
+
|
|
512
|
+
def flush():
|
|
513
|
+
s = ''.join(cur).strip()
|
|
514
|
+
if s:
|
|
515
|
+
out.append(s)
|
|
516
|
+
cur.clear()
|
|
517
|
+
|
|
518
|
+
while i < n:
|
|
519
|
+
c = command[i]
|
|
520
|
+
if quote:
|
|
521
|
+
cur.append(c)
|
|
522
|
+
if c == '\\' and quote != "'" and i + 1 < n:
|
|
523
|
+
cur.append(command[i + 1])
|
|
524
|
+
i += 2
|
|
525
|
+
continue
|
|
526
|
+
if c == quote:
|
|
527
|
+
quote = None
|
|
528
|
+
i += 1
|
|
529
|
+
continue
|
|
530
|
+
if c == '\\' and i + 1 < n:
|
|
531
|
+
cur.append(command[i:i + 2])
|
|
532
|
+
i += 2
|
|
533
|
+
continue
|
|
534
|
+
if c in ('"', "'", '`'):
|
|
535
|
+
quote = c
|
|
536
|
+
cur.append(c)
|
|
537
|
+
i += 1
|
|
538
|
+
continue
|
|
539
|
+
if c == '#' and (i == 0 or command[i - 1] in ' \t\n;&|('):
|
|
540
|
+
j = command.find('\n', i)
|
|
541
|
+
i = n if j < 0 else j
|
|
542
|
+
continue
|
|
543
|
+
if command.startswith('<<', i) and not command.startswith('<<<', i):
|
|
544
|
+
m = _HEREDOC_START_RE.match(command, i)
|
|
545
|
+
if m:
|
|
546
|
+
pending.append((m.group(3), m.group(1) == '-'))
|
|
547
|
+
cur.append(m.group(0))
|
|
548
|
+
i = m.end()
|
|
549
|
+
continue
|
|
550
|
+
if c == '\n':
|
|
551
|
+
i += 1
|
|
552
|
+
if pending:
|
|
553
|
+
for delim, strip_tabs in pending:
|
|
554
|
+
body = []
|
|
555
|
+
while i < n:
|
|
556
|
+
j = command.find('\n', i)
|
|
557
|
+
line = command[i:n if j < 0 else j]
|
|
558
|
+
i = n if j < 0 else j + 1
|
|
559
|
+
if (line.lstrip('\t') if strip_tabs else line).strip() == delim:
|
|
560
|
+
break
|
|
561
|
+
body.append(line)
|
|
562
|
+
if bodies:
|
|
563
|
+
cur.append('\n' + '\n'.join(body))
|
|
564
|
+
pending = []
|
|
565
|
+
flush()
|
|
566
|
+
continue
|
|
567
|
+
if command.startswith('&&', i) or command.startswith('||', i):
|
|
568
|
+
flush()
|
|
569
|
+
i += 2
|
|
570
|
+
continue
|
|
571
|
+
if c == '&' and ((i > 0 and command[i - 1] == '>') or command.startswith('&>', i)):
|
|
572
|
+
cur.append(c)
|
|
573
|
+
i += 1
|
|
574
|
+
continue
|
|
575
|
+
if c == '|' and i > 0 and command[i - 1] == '>':
|
|
576
|
+
cur.append(c) # `>|`(上書きのリダイレクト)は縦棒ではない(便 2.5)
|
|
577
|
+
i += 1
|
|
578
|
+
continue
|
|
579
|
+
if c in ';|&':
|
|
580
|
+
flush()
|
|
581
|
+
i += 1
|
|
582
|
+
continue
|
|
583
|
+
cur.append(c)
|
|
584
|
+
i += 1
|
|
585
|
+
flush()
|
|
586
|
+
return out
|
|
587
|
+
|
|
588
|
+
|
|
483
589
|
def tokens_of(segment: str):
|
|
484
590
|
"""1セグメントを語に割る。クォートが壊れていても落ちない(安全側で素朴分割へ)。"""
|
|
485
591
|
try:
|
|
@@ -704,7 +810,10 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
|
|
|
704
810
|
hit = _rm_danger(inner)
|
|
705
811
|
if hit:
|
|
706
812
|
return f'{hit} ({where})'
|
|
707
|
-
|
|
813
|
+
hit = _guard_write_scan(inner, cwd)
|
|
814
|
+
if hit:
|
|
815
|
+
return f'{hit} ({where})'
|
|
816
|
+
for seg in _real_segments(inner):
|
|
708
817
|
toks = tokens_of(seg)
|
|
709
818
|
v = verb_of(toks)
|
|
710
819
|
hit = (_secret_danger(seg, v, toks, cwd) or _bulk_copy_danger(v, toks, cwd)
|
|
@@ -1103,6 +1212,11 @@ _VALUE_FLAGS = frozenset({
|
|
|
1103
1212
|
'-c', '-C', '--account-id', '--site', '--env', '-e', '--repo', '-R', '--schema',
|
|
1104
1213
|
})
|
|
1105
1214
|
|
|
1215
|
+
# 前置き(npm / pnpm / yarn / npx)の、値を1つ取る旗(`--filter <部品>` など)。
|
|
1216
|
+
_RUNNER_VALUE_FLAGS = frozenset({
|
|
1217
|
+
'--filter', '-F', '--dir', '-C', '--workspace', '-w', '--prefix', '--package', '-p',
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1106
1220
|
# 作業場所が変数などで決まり、確かめられないことを表す印(None=「指定なし=今の場所」とは別)。
|
|
1107
1221
|
_UNKNOWN_DIR = object()
|
|
1108
1222
|
|
|
@@ -1130,13 +1244,34 @@ def _tool_words(tokens):
|
|
|
1130
1244
|
return []
|
|
1131
1245
|
head = rest[0].split('/')[-1].lower()
|
|
1132
1246
|
if head in _TOOL_RUNNERS or head in _PKG_MANAGERS:
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1247
|
+
# ★`pnpm --filter <部品> exec <道具>` / `npm -w <部品> exec -- <道具>` / `npx -p <部品> <道具>` /
|
|
1248
|
+
# `yarn workspace <部品> <道具>` の前置きを剥がす(便 ad6b5db9・便 30879701 の実機)。
|
|
1249
|
+
j, saw_exec = 1, head in _TOOL_RUNNERS
|
|
1250
|
+
while j < len(rest):
|
|
1251
|
+
t = rest[j]
|
|
1252
|
+
if t in _RUNNER_VALUE_FLAGS:
|
|
1253
|
+
j += 2
|
|
1254
|
+
continue
|
|
1255
|
+
if t.startswith('-'):
|
|
1256
|
+
j += 1
|
|
1257
|
+
continue
|
|
1258
|
+
if t.lower() in _PKG_EXEC_WORDS:
|
|
1259
|
+
saw_exec = True
|
|
1260
|
+
j += 1
|
|
1261
|
+
continue
|
|
1262
|
+
if head == 'yarn' and t.lower() == 'workspace':
|
|
1263
|
+
j += 2
|
|
1264
|
+
continue
|
|
1265
|
+
break
|
|
1266
|
+
nxt = _strip_version(rest[j].split('/')[-1].lower()) if j < len(rest) else ''
|
|
1267
|
+
if saw_exec or nxt in _KNOWN_TOOLS:
|
|
1139
1268
|
rest = rest[j:]
|
|
1269
|
+
if not rest:
|
|
1270
|
+
return [] # 前置きだけで道具が無い(`npx` だけ等)。★例外で落とさない。
|
|
1271
|
+
# 前置きの旗(-p・-w 等)は、頭が npm / pnpm / yarn のまま残ったときだけ値ごと外す
|
|
1272
|
+
# (`mkdir -p` や `grep -w` の後ろの語を消さないため)。
|
|
1273
|
+
value_flags = _VALUE_FLAGS | (_RUNNER_VALUE_FLAGS
|
|
1274
|
+
if rest[0].split('/')[-1].lower() in _PKG_MANAGERS else frozenset())
|
|
1140
1275
|
words = []
|
|
1141
1276
|
skip = False
|
|
1142
1277
|
for k, t in enumerate(rest):
|
|
@@ -1144,13 +1279,18 @@ def _tool_words(tokens):
|
|
|
1144
1279
|
skip = False
|
|
1145
1280
|
continue
|
|
1146
1281
|
if k > 0 and t.startswith('-'):
|
|
1147
|
-
if t in
|
|
1282
|
+
if t in value_flags:
|
|
1148
1283
|
skip = True
|
|
1149
1284
|
continue
|
|
1150
|
-
words.append(t.split('/')[-1].lower() if k == 0 else t.lower())
|
|
1285
|
+
words.append(_strip_version(t.split('/')[-1].lower()) if k == 0 else t.lower())
|
|
1151
1286
|
return words
|
|
1152
1287
|
|
|
1153
1288
|
|
|
1289
|
+
def _strip_version(name: str) -> str:
|
|
1290
|
+
"""`wrangler@3` → `wrangler`(版の指定を外す)。`@scope` の頭の @ は残す。"""
|
|
1291
|
+
return name.split('@', 1)[0] if '@' in name[1:] else name
|
|
1292
|
+
|
|
1293
|
+
|
|
1154
1294
|
def _current_branch_targets(cwd):
|
|
1155
1295
|
"""cwd のリポで、引数なしの push が書き込む先のブランチ名の集合。確かめられなければ None。
|
|
1156
1296
|
|
|
@@ -1253,12 +1393,92 @@ _API_SECRET_RE = re.compile(
|
|
|
1253
1393
|
r'|/environments/[^/]+/(secrets|variables)(/|$)')
|
|
1254
1394
|
_API_REPO_RE = re.compile(r'^repos/[^/]+/[^/]+/?$')
|
|
1255
1395
|
|
|
1396
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1397
|
+
# API で main の先端を書き換える形(DENY・便 2.5・便 fc22bc34 の要判断1)
|
|
1398
|
+
# ・git/refs/heads/<保護ブランチ> の PATCH・POST・PUT・DELETE(先端の差し替え・消去)
|
|
1399
|
+
# ・git/refs の POST で ref に保護ブランチを書く形
|
|
1400
|
+
# ・contents の PUT・DELETE で保護ブランチのファイルを直接書く形(branch を書かない=既定の
|
|
1401
|
+
# ブランチ=main へ書くので DENY)
|
|
1402
|
+
# ・GraphQL の createCommitOnBranch・updateRefs(保護ブランチ)・updateRef(先が確かめられない)
|
|
1403
|
+
# ★git/commits・git/trees を作るだけでは main は変わらない。「作って先端を差し替える一連の形」は、
|
|
1404
|
+
# 先端を差し替える一手(refs の書き換え)で止まる。
|
|
1405
|
+
# ★書き込む先のブランチが変数($BR・{branch})で確かめられないときは DENY に倒す(main 直 push と同じ)。
|
|
1406
|
+
# ★作業ブランチへの書き込み(branch=feat/…)は通す。
|
|
1407
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1408
|
+
_API_REFS_RE = re.compile(r'(^|/)git/refs(/(?P<ref>.+))?/?$')
|
|
1409
|
+
_API_CONTENTS_RE = re.compile(r'(^|/)contents(/|$)')
|
|
1410
|
+
# 本文の中の「ブランチ名」の書き方(-f branch=… / "branch":"…" / branchName:"…" / ref=refs/heads/…)。
|
|
1411
|
+
_BODY_BRANCH_RE = re.compile(
|
|
1412
|
+
r'''["']?\b(branch|branchname|qualifiedname|ref|name)["']?\s*[:=]\s*["']?([^\s"',{}\\)]+)''')
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _branch_state(name: str) -> str:
|
|
1416
|
+
"""ブランチ名(refs/heads/ 付きも)が 'protected' / 'unknown' / 'ok' のどれか。"""
|
|
1417
|
+
n = name.strip().strip('"\'')
|
|
1418
|
+
for pre in ('refs/heads/', 'heads/'):
|
|
1419
|
+
if n.startswith(pre):
|
|
1420
|
+
n = n[len(pre):]
|
|
1421
|
+
break
|
|
1422
|
+
if not n or any(c in n for c in '$`{}'):
|
|
1423
|
+
return 'unknown'
|
|
1424
|
+
return 'protected' if n in PROTECTED_BRANCHES else 'ok'
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _body_branches(body_low: str, keys):
|
|
1428
|
+
return [m.group(2) for m in _BODY_BRANCH_RE.finditer(body_low or '') if m.group(1) in keys]
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _main_tip_write(ep: str, method: str, body_low: str):
|
|
1432
|
+
"""API で保護ブランチの先端を書き換える形なら理由。"""
|
|
1433
|
+
m = _API_REFS_RE.search(ep)
|
|
1434
|
+
if m:
|
|
1435
|
+
ref = m.group('ref')
|
|
1436
|
+
if ref:
|
|
1437
|
+
if ref.startswith('heads/') or '$' in ref or '{' in ref:
|
|
1438
|
+
st = _branch_state(ref)
|
|
1439
|
+
if st == 'protected':
|
|
1440
|
+
return f'API で保護ブランチの先端を書き換える ({method} git/refs/{ref})'
|
|
1441
|
+
if st == 'unknown':
|
|
1442
|
+
return f'API で先端を書き換える (git/refs/{ref}・書き込む先を確かめられない)'
|
|
1443
|
+
return None
|
|
1444
|
+
if method == 'POST':
|
|
1445
|
+
refs = _body_branches(body_low, ('ref',))
|
|
1446
|
+
states = {_branch_state(r) for r in refs if r.startswith(('refs/heads/', '$', '{'))}
|
|
1447
|
+
if not refs or 'protected' in states or 'unknown' in states:
|
|
1448
|
+
return 'API で保護ブランチの ref を作る (POST git/refs・書き込む先が保護か確かめられない)'
|
|
1449
|
+
return None
|
|
1450
|
+
if _API_CONTENTS_RE.search(ep) and method in ('PUT', 'DELETE'):
|
|
1451
|
+
branches = _body_branches(body_low, ('branch',))
|
|
1452
|
+
if not branches:
|
|
1453
|
+
return f'API でファイルを既定のブランチへ直接書く ({method} contents・branch の指定なし)'
|
|
1454
|
+
states = {_branch_state(b) for b in branches}
|
|
1455
|
+
if 'protected' in states:
|
|
1456
|
+
return f'API で保護ブランチのファイルを直接書く ({method} contents)'
|
|
1457
|
+
if 'unknown' in states:
|
|
1458
|
+
return f'API でファイルを直接書く ({method} contents・書き込む先を確かめられない)'
|
|
1459
|
+
return None
|
|
1460
|
+
if ep.rstrip('/') == 'graphql':
|
|
1461
|
+
if 'updateref(' in re.sub(r'\s', '', body_low):
|
|
1462
|
+
return 'GraphQL の updateRef (先端の書き換え・書き込む先を確かめられない)'
|
|
1463
|
+
for mut, keys in (('createcommitonbranch', ('branchname', 'qualifiedname', 'branch')),
|
|
1464
|
+
('updaterefs', ('name', 'branchname', 'qualifiedname'))):
|
|
1465
|
+
if mut in body_low:
|
|
1466
|
+
names = _body_branches(body_low, keys)
|
|
1467
|
+
states = {_branch_state(b) for b in names}
|
|
1468
|
+
if not names or 'protected' in states or 'unknown' in states:
|
|
1469
|
+
return f'GraphQL の {mut} (保護ブランチの先端の書き換え・または確かめられない)'
|
|
1470
|
+
return None
|
|
1471
|
+
|
|
1256
1472
|
|
|
1257
1473
|
def _github_api_write(endpoint: str, method: str, body_low: str):
|
|
1258
1474
|
"""GitHub の API への書き込み1件が抜け道なら理由を返す(読み取りは通す)。"""
|
|
1259
1475
|
ep = re.sub(r'^https?://api\.github\.com/', '', endpoint.strip()).lstrip('/').split('?')[0]
|
|
1476
|
+
ep = urllib.parse.unquote(ep)
|
|
1260
1477
|
if method in ('GET', 'HEAD'):
|
|
1261
1478
|
return None
|
|
1479
|
+
hit = _main_tip_write(ep, method, body_low)
|
|
1480
|
+
if hit:
|
|
1481
|
+
return hit
|
|
1262
1482
|
if _API_MERGE_RE.search(ep) and method in ('PUT', 'POST'):
|
|
1263
1483
|
return 'API での合流 (pulls/N/merge・保護を迂回する合流)'
|
|
1264
1484
|
if ep.rstrip('/') == 'graphql' and 'mergepullrequest' in body_low:
|
|
@@ -1401,26 +1621,35 @@ def _bypass_segment_danger(seg, tokens, cwd, command):
|
|
|
1401
1621
|
return None
|
|
1402
1622
|
|
|
1403
1623
|
|
|
1624
|
+
def _cd_dest(toks, here):
|
|
1625
|
+
"""`cd 場所` の後の作業場所。変数・`cd -`・分からないときは _UNKNOWN_DIR。"""
|
|
1626
|
+
after = toks[toks.index('cd') + 1:] if 'cd' in toks else []
|
|
1627
|
+
dest = next((t for t in after if t != '--'), '~')
|
|
1628
|
+
p = os.path.expanduser(dest)
|
|
1629
|
+
if dest == '-' or any(c in dest for c in '$`*?') or here is _UNKNOWN_DIR:
|
|
1630
|
+
return _UNKNOWN_DIR
|
|
1631
|
+
if os.path.isabs(p):
|
|
1632
|
+
return p
|
|
1633
|
+
return os.path.join(here or os.getcwd(), p)
|
|
1634
|
+
|
|
1635
|
+
|
|
1404
1636
|
def _bypass_scan(command: str, cwd=None):
|
|
1405
|
-
"""コマンド全体の抜け道を探す。★`cd 場所` を追って、後ろの push の作業場所に使う。
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
hit = _bypass_segment_danger(seg, toks, here, command) or _main_push_danger(toks, here)
|
|
1422
|
-
if hit:
|
|
1423
|
-
return hit
|
|
1637
|
+
"""コマンド全体の抜け道を探す。★`cd 場所` を追って、後ろの push の作業場所に使う。
|
|
1638
|
+
|
|
1639
|
+
★機械的な区切りで1回、本物の区切り(本文なし)でもう1回測る(便 2.5)。複数行の引用符の
|
|
1640
|
+
中に書いた GraphQL の本文などを、ひとかたまりのまま読むため。止める側は減らない。
|
|
1641
|
+
"""
|
|
1642
|
+
for segs in (split_segments(command), split_commands(command, bodies=False)):
|
|
1643
|
+
here = cwd
|
|
1644
|
+
for seg in segs:
|
|
1645
|
+
toks = tokens_of(seg)
|
|
1646
|
+
v = verb_of(toks)
|
|
1647
|
+
if v == 'cd':
|
|
1648
|
+
here = _cd_dest(toks, here)
|
|
1649
|
+
continue
|
|
1650
|
+
hit = _bypass_segment_danger(seg, toks, here, command) or _main_push_danger(toks, here)
|
|
1651
|
+
if hit:
|
|
1652
|
+
return hit
|
|
1424
1653
|
return None
|
|
1425
1654
|
|
|
1426
1655
|
|
|
@@ -1560,7 +1789,9 @@ def _secret_danger(segment: str, verb: str, tokens, cwd=None):
|
|
|
1560
1789
|
# コミットメッセージや説明文に .env と書いただけで止まる(実際に止まった)。
|
|
1561
1790
|
# その代わり、未知の道具に秘密を渡す形(`mytool .env`)は素通りする=
|
|
1562
1791
|
# 限界として README に明記してある。
|
|
1563
|
-
|
|
1792
|
+
# ★探す道具(rg・ag・ack・ugrep …)も中身を画面に出す=読む(便 2.5・`rg "a|b" .env`)。
|
|
1793
|
+
if verb in READ_VERBS or verb in WRITE_VERBS or verb in EXFIL_VERBS \
|
|
1794
|
+
or verb in _PATTERN_FIRST_VERBS:
|
|
1564
1795
|
return f'{sr} touched by {verb}'
|
|
1565
1796
|
if verb in WRITE_VERBS or is_inplace_edit(verb, tokens):
|
|
1566
1797
|
syr = system_write_reason(a)
|
|
@@ -1578,6 +1809,400 @@ def _secret_danger(segment: str, verb: str, tokens, cwd=None):
|
|
|
1578
1809
|
return None
|
|
1579
1810
|
|
|
1580
1811
|
|
|
1812
|
+
def _real_segments(command: str):
|
|
1813
|
+
"""止める側の読み直しに使う区切り(便 2.5)。機械的な区切りに、本物の区切りを【足す】。
|
|
1814
|
+
|
|
1815
|
+
★機械的な区切り(split_segments)は1件も外さない=止める側は減らない。
|
|
1816
|
+
★本物の区切り(split_commands・本文なし)を足すと、引用符の中の `|` `;` で動詞が千切れた
|
|
1817
|
+
コマンド(`grep -n "x|y" .env` → `y" .env` という切れ端)も、正しい動詞で測り直せる。
|
|
1818
|
+
"""
|
|
1819
|
+
segs = split_segments(command)
|
|
1820
|
+
seen = set(segs)
|
|
1821
|
+
for s in split_commands(command, bodies=False):
|
|
1822
|
+
if s not in seen:
|
|
1823
|
+
seen.add(s)
|
|
1824
|
+
segs.append(s)
|
|
1825
|
+
return segs
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1829
|
+
# 番人の設定への書き込み(DENY・便 2.5・えふさん確定 2026-09-18)
|
|
1830
|
+
#
|
|
1831
|
+
# 【なぜ要るか(便 ad6b5db9 の重大発見1(ア))】書き込みの道具(Write)では止まるのに、Bash の
|
|
1832
|
+
# リダイレクト・tee・cp・mv・sed -i・python3 で開いて書く形では止まらなかった(リダイレクトの
|
|
1833
|
+
# 行き先は秘密とシステム領域しか見ていなかった)。番人の設定を職人が書き換えられると、番人
|
|
1834
|
+
# そのものが外れる。
|
|
1835
|
+
# 【守る場所】~/.claude/settings.json・~/.claude/settings.local.json(と、各現場の
|
|
1836
|
+
# .claude/settings*.json=Write と同じ判定 is_guard_settings_path)・~/.tyhld/hooks 配下・
|
|
1837
|
+
# ~/.codex/hooks.json・~/.codex/config.toml・~/.config/systemd 配下。
|
|
1838
|
+
# ★ホーム直下の隠しフォルダの hooks.json・config.toml は、道具の名前を持たない一般の形で守る
|
|
1839
|
+
# (~/.codex の2つはこれに当たる)。番人の判定に道具名を持ち込まない決め(test/guard-for-codex・
|
|
1840
|
+
# ADR-022)を守るため。ほかの道具の同じ形の登録ファイルも、同じく守る側に入る。
|
|
1841
|
+
# 【止めないもの】読むだけ(cat・grep・diff・jq・cp で外へ写す)。使い捨ての作業場(/tmp の下)に
|
|
1842
|
+
# 作った同じ名前(Write と同じ例外 is_throwaway_path)。
|
|
1843
|
+
# 【どう見るか】本物の区切り(引用符・ヒアドキュメントの本文の中は割らない)で割った各コマンドの
|
|
1844
|
+
# 「書き込む先」だけを見る。報告やコミットの説明に同じ場所の名前が書いてあるだけでは止めない。
|
|
1845
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1846
|
+
|
|
1847
|
+
#: ★~/.claude/settings*.json は Write と同じ名前の判定(is_guard_settings_path)で守る(一覧は持たない)。
|
|
1848
|
+
#: ホーム直下の隠しフォルダに置かれたときだけ、道具の番人の登録・設定と分かる名前。
|
|
1849
|
+
_AGENT_CONFIG_NAMES = frozenset({'hooks.json', 'config.toml'})
|
|
1850
|
+
#: ホームの下の、中身ぜんぶが番人の設定の置き場。
|
|
1851
|
+
_GUARD_HOME_DIRS = ('.tyhld/hooks', '.config/systemd')
|
|
1852
|
+
#: 番人の設定を中に持つ置き場(消す・動かす・フォルダごと写し込むと設定が変わる)。
|
|
1853
|
+
#: ★ほかに、ホーム直下の隠しフォルダで hooks.json・config.toml を持つものも同じ扱い。
|
|
1854
|
+
_GUARD_HOME_PARENTS = ('.claude', '.tyhld', '.config')
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def _expand_path(path: str, cwd=None) -> str:
|
|
1858
|
+
"""字面のパスを、近道を解かずに絶対パスへ($HOME・~・相対を解く)。"""
|
|
1859
|
+
q = normalize_redirect_target(path)
|
|
1860
|
+
for var in ('${HOME}', '$HOME'):
|
|
1861
|
+
if q == var or q.startswith(var + '/'):
|
|
1862
|
+
q = os.path.expanduser('~') + q[len(var):]
|
|
1863
|
+
break
|
|
1864
|
+
q = os.path.expanduser(q)
|
|
1865
|
+
if not os.path.isabs(q):
|
|
1866
|
+
base = cwd if (isinstance(cwd, str) and os.path.isabs(cwd)) else os.getcwd()
|
|
1867
|
+
q = os.path.join(base, q)
|
|
1868
|
+
return os.path.normpath(q)
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def _home_rels(abs_paths):
|
|
1872
|
+
"""絶対パスたちの、ホームからの相対(ホームの外は落とす)。字面のホームと実体のホームの両方。"""
|
|
1873
|
+
homes = {os.path.expanduser('~').rstrip('/'), _home_dir().rstrip('/')}
|
|
1874
|
+
out = []
|
|
1875
|
+
for p in abs_paths:
|
|
1876
|
+
for h in homes:
|
|
1877
|
+
if h and p.startswith(h + '/'):
|
|
1878
|
+
out.append(p[len(h) + 1:])
|
|
1879
|
+
return out
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def guard_config_reason(path: str, cwd=None):
|
|
1883
|
+
"""書き込むと番人の設定が変わる場所なら理由、そうでなければ None(★読むだけには使わない)。
|
|
1884
|
+
|
|
1885
|
+
★字面と、近道(symlink)を解いた実体の両方を見る(別名から本物へ書くのを塞ぐ)。
|
|
1886
|
+
"""
|
|
1887
|
+
if not path or not path.strip():
|
|
1888
|
+
return None
|
|
1889
|
+
try:
|
|
1890
|
+
lit = _expand_path(path, cwd)
|
|
1891
|
+
real = resolved_path(lit)
|
|
1892
|
+
except Exception: # noqa: BLE001
|
|
1893
|
+
return None
|
|
1894
|
+
for p in (lit, real):
|
|
1895
|
+
if is_guard_settings_path(p) and not is_throwaway_path(p):
|
|
1896
|
+
return 'guard settings (.claude/settings*.json)'
|
|
1897
|
+
for rel in _home_rels((lit, real)):
|
|
1898
|
+
parts = rel.split('/')
|
|
1899
|
+
if len(parts) == 2 and parts[0].startswith('.') and parts[1] in _AGENT_CONFIG_NAMES:
|
|
1900
|
+
return f'agent hook settings (~/{rel})'
|
|
1901
|
+
for d in _GUARD_HOME_DIRS:
|
|
1902
|
+
if rel == d or rel.startswith(d + '/'):
|
|
1903
|
+
return f'guard directory (~/{d})'
|
|
1904
|
+
return None
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def _guard_parent_reason(path: str, cwd=None):
|
|
1908
|
+
"""番人の設定を中に持つ置き場そのもの(~/.claude・~/.tyhld・~/.config・現場の .claude・
|
|
1909
|
+
ホーム直下の隠しフォルダで hooks.json か config.toml を持つもの)。"""
|
|
1910
|
+
try:
|
|
1911
|
+
lit = _expand_path(path, cwd)
|
|
1912
|
+
real = resolved_path(lit)
|
|
1913
|
+
except Exception: # noqa: BLE001
|
|
1914
|
+
return None
|
|
1915
|
+
for p in (lit, real):
|
|
1916
|
+
if p.rstrip('/').split('/')[-1] == '.claude' and not is_throwaway_path(p):
|
|
1917
|
+
return 'guard settings folder (.claude)'
|
|
1918
|
+
for rel in _home_rels((lit, real)):
|
|
1919
|
+
rel = rel.rstrip('/')
|
|
1920
|
+
if rel in _GUARD_HOME_PARENTS or any(d.startswith(rel + '/') for d in _GUARD_HOME_DIRS):
|
|
1921
|
+
return f'guard settings folder (~/{rel})'
|
|
1922
|
+
if '/' not in rel and rel.startswith('.') and any(
|
|
1923
|
+
os.path.exists(os.path.join(os.path.expanduser('~'), rel, n)) for n in _AGENT_CONFIG_NAMES):
|
|
1924
|
+
return f'agent hook settings folder (~/{rel})'
|
|
1925
|
+
return None
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
# リダイレクトの記号(語の頭に付いたもの)。`2>&1` `>&2` のような写しは書き込みではない。
|
|
1929
|
+
_REDIRECT_TOKEN_RE = re.compile(r'^\d*(>>|>\||&>>|&>|>&|>|<<<|<<-?|<>|<)')
|
|
1930
|
+
#: 書き込む先が「最後の引数(か -t の値)」の道具。それより前は読むだけ。
|
|
1931
|
+
_COPY_VERBS = frozenset({'cp', 'install', 'ln', 'rsync', 'ditto'})
|
|
1932
|
+
#: 引数ぜんぶが書き換え・削除の対象になる道具。
|
|
1933
|
+
_TOUCH_ALL_VERBS = frozenset({
|
|
1934
|
+
'tee', 'rm', 'rmdir', 'shred', 'truncate', 'touch', 'chmod', 'chown', 'chgrp', 'patch',
|
|
1935
|
+
'sponge', 'unlink', 'mkdir',
|
|
1936
|
+
})
|
|
1937
|
+
#: その場で書き換える旗を持つ道具(sed -i・perl -i・yq -i など)。
|
|
1938
|
+
_INPLACE_VERBS = frozenset({'sed', 'perl', 'ruby', 'yq'})
|
|
1939
|
+
#: systemctl --user の、~/.config/systemd の中を書き換える操作。
|
|
1940
|
+
_SYSTEMCTL_WRITE = frozenset({
|
|
1941
|
+
'enable', 'disable', 'reenable', 'edit', 'link', 'mask', 'unmask', 'revert', 'preset',
|
|
1942
|
+
'set-property', 'add-wants', 'add-requires',
|
|
1943
|
+
})
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def _real_redirect_targets(segment: str):
|
|
1947
|
+
"""引用符の外にある本物のリダイレクトの書き込み先(`>` `>>` `>|` `&>`)。"""
|
|
1948
|
+
out = []
|
|
1949
|
+
head = segment.split('\n', 1)[0] if '<<' in segment else segment
|
|
1950
|
+
quote = None
|
|
1951
|
+
i, n = 0, len(head)
|
|
1952
|
+
while i < n:
|
|
1953
|
+
c = head[i]
|
|
1954
|
+
if quote:
|
|
1955
|
+
if c == '\\' and quote == '"':
|
|
1956
|
+
i += 2
|
|
1957
|
+
continue
|
|
1958
|
+
if c == quote:
|
|
1959
|
+
quote = None
|
|
1960
|
+
i += 1
|
|
1961
|
+
continue
|
|
1962
|
+
if c == '\\':
|
|
1963
|
+
i += 2
|
|
1964
|
+
continue
|
|
1965
|
+
if c in '"\'`':
|
|
1966
|
+
quote = c
|
|
1967
|
+
i += 1
|
|
1968
|
+
continue
|
|
1969
|
+
if c != '>':
|
|
1970
|
+
i += 1
|
|
1971
|
+
continue
|
|
1972
|
+
j = i + 1
|
|
1973
|
+
if j < n and head[j] in '>|':
|
|
1974
|
+
j += 1
|
|
1975
|
+
if j < n and head[j] in '&(':
|
|
1976
|
+
i = j + 1 # `>&2` / `2>&1`(写し)・`>(…)`(プロセス置換)は書き込み先ではない
|
|
1977
|
+
continue
|
|
1978
|
+
while j < n and head[j] in ' \t':
|
|
1979
|
+
j += 1
|
|
1980
|
+
k, q = j, None
|
|
1981
|
+
while k < n:
|
|
1982
|
+
ch = head[k]
|
|
1983
|
+
if q:
|
|
1984
|
+
if ch == q:
|
|
1985
|
+
q = None
|
|
1986
|
+
elif ch in '"\'':
|
|
1987
|
+
q = ch
|
|
1988
|
+
elif ch in ' \t;|&<>()':
|
|
1989
|
+
break
|
|
1990
|
+
k += 1
|
|
1991
|
+
word = head[j:k].replace('"', '').replace("'", '')
|
|
1992
|
+
if word:
|
|
1993
|
+
out.append(word)
|
|
1994
|
+
i = k
|
|
1995
|
+
return out
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def _plain_args(tokens, start):
|
|
1999
|
+
"""動詞より後ろの、リダイレクトを除いた引数。"""
|
|
2000
|
+
out, skip = [], False
|
|
2001
|
+
for t in tokens[start:]:
|
|
2002
|
+
if skip:
|
|
2003
|
+
skip = False
|
|
2004
|
+
continue
|
|
2005
|
+
m = _REDIRECT_TOKEN_RE.match(t)
|
|
2006
|
+
if m:
|
|
2007
|
+
if m.end() == len(t):
|
|
2008
|
+
skip = True
|
|
2009
|
+
continue
|
|
2010
|
+
out.append(t)
|
|
2011
|
+
return out
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
def _verb_pos(tokens):
|
|
2015
|
+
"""verb_of と同じ読み方で、動詞の位置を返す(無ければ len(tokens))。"""
|
|
2016
|
+
i = 0
|
|
2017
|
+
while i < len(tokens):
|
|
2018
|
+
t = tokens[i]
|
|
2019
|
+
if _ASSIGN_RE.match(t):
|
|
2020
|
+
i += 1
|
|
2021
|
+
continue
|
|
2022
|
+
base = t.split('/')[-1]
|
|
2023
|
+
if base in _WRAPPERS:
|
|
2024
|
+
i += 1
|
|
2025
|
+
while i < len(tokens) and tokens[i].startswith('-'):
|
|
2026
|
+
i += 1
|
|
2027
|
+
if base in ('timeout', 'nice', 'ionice') and i < len(tokens) \
|
|
2028
|
+
and not tokens[i].startswith('-'):
|
|
2029
|
+
i += 1
|
|
2030
|
+
continue
|
|
2031
|
+
return i
|
|
2032
|
+
return len(tokens)
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
def _guard_write_segment(segment: str, cwd=None):
|
|
2036
|
+
"""本物の1コマンドが番人の設定へ書くなら理由。"""
|
|
2037
|
+
for t in _real_redirect_targets(segment):
|
|
2038
|
+
r = guard_config_reason(t, cwd)
|
|
2039
|
+
if r:
|
|
2040
|
+
return f'redirect into {r}'
|
|
2041
|
+
head = segment.split('\n', 1)[0] if '<<' in segment else segment
|
|
2042
|
+
tokens = tokens_of(head)
|
|
2043
|
+
pos = _verb_pos(tokens)
|
|
2044
|
+
if pos >= len(tokens):
|
|
2045
|
+
return None
|
|
2046
|
+
verb = tokens[pos].split('/')[-1]
|
|
2047
|
+
args = _plain_args(tokens, pos + 1)
|
|
2048
|
+
paths = [a for a in args if not a.startswith('-')]
|
|
2049
|
+
targets, parents = [], []
|
|
2050
|
+
if verb in _TOUCH_ALL_VERBS:
|
|
2051
|
+
targets = paths
|
|
2052
|
+
if verb in ('rm', 'rmdir', 'shred', 'unlink'):
|
|
2053
|
+
parents = paths
|
|
2054
|
+
elif verb == 'mv':
|
|
2055
|
+
targets = parents = paths # 動かす元も、動かした先も変わる
|
|
2056
|
+
elif verb in _COPY_VERBS:
|
|
2057
|
+
dest = None
|
|
2058
|
+
for k, a in enumerate(args):
|
|
2059
|
+
if a in ('-t', '--target-directory') and k + 1 < len(args):
|
|
2060
|
+
dest = args[k + 1]
|
|
2061
|
+
elif a.startswith('--target-directory='):
|
|
2062
|
+
dest = a.split('=', 1)[1]
|
|
2063
|
+
srcs = [p for p in paths if p != dest]
|
|
2064
|
+
if dest is None and paths:
|
|
2065
|
+
dest, srcs = paths[-1], paths[:-1]
|
|
2066
|
+
if dest:
|
|
2067
|
+
targets = [dest] + [os.path.join(_expand_path(dest, cwd), os.path.basename(s.rstrip('/')))
|
|
2068
|
+
for s in srcs if s.rstrip('/')]
|
|
2069
|
+
recursive = any(a in ('-r', '-R', '--recursive', '-a', '--archive')
|
|
2070
|
+
or (re.match(r'^-[A-Za-z]+$', a) and ('r' in a or 'R' in a or 'a' in a))
|
|
2071
|
+
for a in args)
|
|
2072
|
+
if recursive or verb == 'rsync':
|
|
2073
|
+
parents = [dest]
|
|
2074
|
+
elif verb == 'dd':
|
|
2075
|
+
targets = [a[3:] for a in args if a.startswith('of=')]
|
|
2076
|
+
elif verb in _INPLACE_VERBS and (
|
|
2077
|
+
is_inplace_edit(verb if verb != 'yq' else 'sed', tokens[pos:])
|
|
2078
|
+
or any(re.match(r'^-[a-zA-Z]*i', a) for a in args if not a.startswith('--'))):
|
|
2079
|
+
targets = paths # `perl -pi -e …` のように旗をまとめた形も
|
|
2080
|
+
elif verb == 'systemctl' and '--user' in args:
|
|
2081
|
+
if any(a in _SYSTEMCTL_WRITE for a in paths):
|
|
2082
|
+
return 'guard directory (~/.config/systemd) changed by systemctl --user'
|
|
2083
|
+
for p in targets:
|
|
2084
|
+
r = guard_config_reason(p, cwd)
|
|
2085
|
+
if r:
|
|
2086
|
+
return f'{r} written by {verb}'
|
|
2087
|
+
for p in parents:
|
|
2088
|
+
r = _guard_parent_reason(p, cwd)
|
|
2089
|
+
if r:
|
|
2090
|
+
return f'{r} changed by {verb}'
|
|
2091
|
+
return None
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def _guard_write_scan(command: str, cwd=None):
|
|
2095
|
+
"""コマンド全体で、番人の設定への書き込みを探す(本物の区切り・`cd 場所` を追う)。
|
|
2096
|
+
|
|
2097
|
+
★同じコマンドの中で先に決めた変数(`P=/tmp/x; … > $P/.claude/settings.json`)は解いてから測る。
|
|
2098
|
+
解けない変数は、そのまま(=使い捨ての作業場と確かめられない=守る側)で測る。
|
|
2099
|
+
"""
|
|
2100
|
+
here, env = cwd, {}
|
|
2101
|
+
for seg in split_commands(command, bodies=False):
|
|
2102
|
+
toks = tokens_of(seg)
|
|
2103
|
+
if toks and all(_ASSIGN_RE.match(t) for t in toks):
|
|
2104
|
+
for t in toks:
|
|
2105
|
+
k, _, v = t.partition('=')
|
|
2106
|
+
env[k] = _expand_vars(v, env)
|
|
2107
|
+
continue
|
|
2108
|
+
seg = _expand_vars(seg, env)
|
|
2109
|
+
toks = tokens_of(seg)
|
|
2110
|
+
if verb_of(toks) == 'cd':
|
|
2111
|
+
here = _cd_dest(toks, here)
|
|
2112
|
+
continue
|
|
2113
|
+
where = None if here is _UNKNOWN_DIR else here
|
|
2114
|
+
hit = _guard_write_segment(seg, where)
|
|
2115
|
+
if hit:
|
|
2116
|
+
return hit
|
|
2117
|
+
for code in _code_payloads(seg, command):
|
|
2118
|
+
hit = _code_guard_write(code, where)
|
|
2119
|
+
if hit:
|
|
2120
|
+
return hit
|
|
2121
|
+
return None
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _expand_vars(text: str, env) -> str:
|
|
2125
|
+
"""同じコマンドの中で決めた変数だけを解く($HOME などほかの変数は触らない)。"""
|
|
2126
|
+
if not env:
|
|
2127
|
+
return text
|
|
2128
|
+
return re.sub(r'\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?',
|
|
2129
|
+
lambda m: env.get(m.group(1), m.group(0)), text)
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
def _code_payloads(segment: str, command: str):
|
|
2133
|
+
"""1コマンドがその場で走らせるプログラムの中身(python3 -c・node -e・python3 - <<EOF)。"""
|
|
2134
|
+
head = segment.split('\n', 1)[0]
|
|
2135
|
+
toks = tokens_of(head)
|
|
2136
|
+
if verb_of(toks) not in _CODE_RUNNERS:
|
|
2137
|
+
return []
|
|
2138
|
+
out = [toks[k + 1] for k, t in enumerate(toks) if t in _CODE_FLAGS and k + 1 < len(toks)]
|
|
2139
|
+
m = _HEREDOC_START_RE.search(head)
|
|
2140
|
+
if m:
|
|
2141
|
+
body = _HEREDOC_RE.search(command[command.find(m.group(0)):] if m.group(0) in command else '')
|
|
2142
|
+
if body:
|
|
2143
|
+
out.append(body.group(2))
|
|
2144
|
+
return out
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
# プログラム(python3 -c・node -e・python3 - <<EOF)の中の、番人の設定の場所の書き方。
|
|
2148
|
+
# ① 文字列そのものが場所('~/.claude/settings.json')→ guard_config_reason で測る
|
|
2149
|
+
# ② 部品で組み立てた形(Path.home() / '.claude' / 'settings.json'・join(h, '.<隠しフォルダ>', 'hooks.json'))
|
|
2150
|
+
# ★報告の文の中に場所の名前が出てくるだけ(空白を含む文字列)は場所と読まない。
|
|
2151
|
+
_CODE_GUARD_JOIN_RE = re.compile(
|
|
2152
|
+
r'''['"]\.claude['"]\s*[,/+]\s*['"]settings[\w.]*\.json['"]'''
|
|
2153
|
+
r'''|['"]\.tyhld['"]\s*[,/+]\s*['"]hooks['"/]'''
|
|
2154
|
+
r'''|['"]\.[\w-]+['"]\s*[,/+]\s*['"](hooks\.json|config\.toml)['"]'''
|
|
2155
|
+
r'''|['"]\.config['"]\s*[,/+]\s*['"]systemd['"/]''')
|
|
2156
|
+
_CODE_PATH_LITERAL_RE = re.compile(r'''['"`]([^'"`\s*?\[\]]{1,300})['"`]''')
|
|
2157
|
+
# ホームを式で足した後ろの部分(homedir() + '/.codex/hooks.json'・`${os.homedir()}/.claude/…`)。
|
|
2158
|
+
_CODE_HOME_TAIL_RE = re.compile(r'^(?:\$\{[^}]*\})?(/\..+)$')
|
|
2159
|
+
# プログラムの中の「書く」書き方(開いて書く・写す・動かす・消す)。
|
|
2160
|
+
_CODE_WRITE_RE = re.compile(
|
|
2161
|
+
r'''open\s*\([^)]*['"](?:[wax]|r\+)[bt+]*['"]|mode\s*=\s*['"](?:[wax]|r\+)'''
|
|
2162
|
+
r'''|\.write(_text|_bytes)?\s*\(|json\.dump\s*\(|shutil\.(copy\w*|move|rmtree)'''
|
|
2163
|
+
r'''|os\.(rename|replace|remove|unlink|symlink|rmdir)\s*\(|\.(unlink|rename|replace|symlink_to|touch)\s*\('''
|
|
2164
|
+
r'''|writefile|appendfile|fs\.(write|copy|rename|rm|unlink|symlink|cp)\w*'''
|
|
2165
|
+
r'''|file\.write|io\.write|['"]>>?['"]''', re.IGNORECASE)
|
|
2166
|
+
|
|
2167
|
+
|
|
2168
|
+
def _code_mentions_guard(stmt: str, cwd=None) -> bool:
|
|
2169
|
+
if _CODE_GUARD_JOIN_RE.search(stmt):
|
|
2170
|
+
return True
|
|
2171
|
+
for m in _CODE_PATH_LITERAL_RE.finditer(stmt):
|
|
2172
|
+
lit = m.group(1)
|
|
2173
|
+
if '/' in lit and guard_config_reason(lit, cwd):
|
|
2174
|
+
return True
|
|
2175
|
+
tail = _CODE_HOME_TAIL_RE.match(lit)
|
|
2176
|
+
if tail and guard_config_reason('~' + tail.group(1), cwd):
|
|
2177
|
+
return True
|
|
2178
|
+
return False
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def _code_guard_write(text: str, cwd=None):
|
|
2182
|
+
"""プログラムの中で、番人の設定の場所を書き換えるなら理由。★読むだけ(json.load)は止めない。
|
|
2183
|
+
|
|
2184
|
+
★場所が【書く呼び出しと同じ文】に出てくるか、場所を入れた変数が書く文で使われるときだけ止める
|
|
2185
|
+
(別のファイルを書き換えるプログラムの文の中に、場所の名前が書いてあるだけでは止めない)。
|
|
2186
|
+
"""
|
|
2187
|
+
if not text or not _CODE_WRITE_RE.search(text):
|
|
2188
|
+
return None
|
|
2189
|
+
names = set()
|
|
2190
|
+
for stmt in re.split(r'[\n;]', text):
|
|
2191
|
+
if not stmt.strip():
|
|
2192
|
+
continue
|
|
2193
|
+
if _code_mentions_guard(stmt, cwd):
|
|
2194
|
+
if _CODE_WRITE_RE.search(stmt):
|
|
2195
|
+
return 'guard settings written by a program'
|
|
2196
|
+
m = re.match(r'\s*(?:const |let |var |my )?\$?([A-Za-z_][A-Za-z0-9_]*)\s*=[^=]', stmt)
|
|
2197
|
+
if m:
|
|
2198
|
+
names.add(m.group(1))
|
|
2199
|
+
continue
|
|
2200
|
+
if names and _CODE_WRITE_RE.search(stmt) and any(
|
|
2201
|
+
re.search(rf'(?<![\w.]){re.escape(n)}\b', stmt) for n in names):
|
|
2202
|
+
return 'guard settings written by a program'
|
|
2203
|
+
return None
|
|
2204
|
+
|
|
2205
|
+
|
|
1581
2206
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
1582
2207
|
# 2) 3関門(gate)
|
|
1583
2208
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
@@ -1610,42 +2235,120 @@ _SQL_TEXT_ONLY_VERBS = frozenset({
|
|
|
1610
2235
|
})
|
|
1611
2236
|
|
|
1612
2237
|
|
|
1613
|
-
|
|
1614
|
-
|
|
2238
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2239
|
+
# ★関門は「実際に動く道具」で決める(便 ad6b5db9・えふさん確定 2026-09-18)
|
|
2240
|
+
#
|
|
2241
|
+
# 【直したずれ(便 30879701 の実機)】
|
|
2242
|
+
# ・本物の本番反映 `pnpm --filter <部品> exec wrangler deploy --env production` が素通りした
|
|
2243
|
+
# (頭の pnpm --filter … exec を剥がさず、wrangler deploy も関門に入っていなかった)。
|
|
2244
|
+
# ・報告をヒアドキュメントで書くだけの Bash が、本文の「反映」の文字で関門に当たり8分止まった。
|
|
2245
|
+
# 【どう決めるか】
|
|
2246
|
+
# 1. split_commands で本物の区切りだけで割る(引用符・ヒアドキュメントの中は割らない)。
|
|
2247
|
+
# 2. 頭の前置き(pnpm --filter … exec・pnpm exec・npm exec・npx・yarn・bunx・env 変数=…・time・
|
|
2248
|
+
# nohup・xargs 等)を剥がして、実際に動く道具の語の並び(_tool_words)を見る。
|
|
2249
|
+
# 3. 文字を扱うだけの道具(echo・cat・tee・grep・rg・git・gh …)は関門にしない。
|
|
2250
|
+
# その引数・ヒアドキュメントの本文に何が書かれていても、実行はされないため。
|
|
2251
|
+
# 4. シェル(bash -c "…" / bash <<EOF)は、中身を同じ物差しで測り直す。
|
|
2252
|
+
# 5. SQL の字面は「実行しうる入口」のときだけ見る=DB の道具・移行の道具・その場で走らせる形
|
|
2253
|
+
# (python3 -c・node -e・python3 - <<EOF・DB の道具へ流す形)・知らない実行ファイル。
|
|
2254
|
+
# ★止める側(deny)はこの判定の前に済んでいて、ここでは1件も変えない。
|
|
2255
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2256
|
+
_DB_CLIENTS = frozenset({'psql', 'mysql', 'mongosh', 'dropdb', 'createdb', 'pg_restore', 'pg_dump'})
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
def _gate_release(words, tokens):
|
|
2260
|
+
"""本番反映の関門(実際に動く道具の語の並びで見る)。"""
|
|
2261
|
+
if not words:
|
|
2262
|
+
return None
|
|
2263
|
+
v = words[0]
|
|
2264
|
+
if v == 'vercel':
|
|
1615
2265
|
if '--prod' in tokens or '--production' in tokens:
|
|
1616
2266
|
return 'vercel --prod'
|
|
1617
|
-
for
|
|
1618
|
-
if
|
|
1619
|
-
return f'vercel {
|
|
1620
|
-
if
|
|
1621
|
-
|
|
1622
|
-
|
|
2267
|
+
for w in words[1:]:
|
|
2268
|
+
if w in ('promote', 'alias', 'rollback'):
|
|
2269
|
+
return f'vercel {w}'
|
|
2270
|
+
if v == 'wrangler' and '--dry-run' not in tokens and (
|
|
2271
|
+
words[1:2] in (['deploy'], ['publish'])
|
|
2272
|
+
or words[1:3] in (['versions', 'deploy'], ['pages', 'deploy'])):
|
|
2273
|
+
return 'wrangler ' + (' '.join(words[1:3]) if words[1] in ('versions', 'pages') else words[1])
|
|
2274
|
+
if v in _PKG_MANAGERS and 'publish' in words[1:3]:
|
|
2275
|
+
return f'{v} publish'
|
|
2276
|
+
if words[:3] == ['supabase', 'functions', 'deploy']:
|
|
1623
2277
|
return 'supabase functions deploy'
|
|
1624
|
-
# 「deploy
|
|
1625
|
-
if
|
|
2278
|
+
# 「deploy の語」+「本番の指定」の組み合わせ(新しい道具にも効く一般形)。
|
|
2279
|
+
if 'deploy' in words and ('--prod' in tokens or '--production' in tokens):
|
|
1626
2280
|
return 'deploy --prod'
|
|
1627
2281
|
return None
|
|
1628
2282
|
|
|
1629
2283
|
|
|
1630
|
-
def _gate_db(
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2284
|
+
def _gate_db(words, tokens, segment: str):
|
|
2285
|
+
"""DB 実行の関門(実際に動く道具の語の並びで見る)。"""
|
|
2286
|
+
if not words:
|
|
2287
|
+
return None
|
|
2288
|
+
v = words[0]
|
|
2289
|
+
if v in _DB_CLIENTS:
|
|
2290
|
+
return f'database client ({v})'
|
|
2291
|
+
joined = ' '.join(words)
|
|
2292
|
+
if re.match(r'prisma (migrate|db)( |$)', joined):
|
|
1634
2293
|
return 'prisma migrate/db'
|
|
1635
|
-
if re.
|
|
2294
|
+
if re.match(r'supabase (db (push|reset|query|execute)|migration up)( |$)', joined):
|
|
1636
2295
|
return 'supabase db/migration'
|
|
1637
|
-
if re.
|
|
2296
|
+
if re.match(r'drizzle-kit (push|migrate)( |$)', joined):
|
|
1638
2297
|
return 'drizzle-kit push/migrate'
|
|
1639
|
-
# SQL
|
|
1640
|
-
#
|
|
1641
|
-
|
|
1642
|
-
# 一方、psql/mysql への `| psql` は分割後に psql セグメントが別途上で関門に当たり、
|
|
1643
|
-
# run-sql / apply のような未知のSQL実行入口は safe 集合に無いのでここで関門に落ちる。
|
|
1644
|
-
if verb not in _SQL_TEXT_ONLY_VERBS and _SQL_GATE_RE.search(segment):
|
|
2298
|
+
# SQL の字面(DDL / GRANT / RLS)。★ここへ来るのは「実行しうる入口」だけ
|
|
2299
|
+
# (文字を扱うだけの道具とシェルは _gate_one で先に振り分け済み)。
|
|
2300
|
+
if _SQL_GATE_RE.search(segment):
|
|
1645
2301
|
return 'SQL DDL/GRANT/RLS'
|
|
1646
2302
|
return None
|
|
1647
2303
|
|
|
1648
2304
|
|
|
2305
|
+
def _shell_payloads(head_tokens, body):
|
|
2306
|
+
"""シェルに渡された中身(-c の文字列・ヒアドキュメントの本文)。"""
|
|
2307
|
+
out = []
|
|
2308
|
+
for k, t in enumerate(head_tokens):
|
|
2309
|
+
if t in _CODE_FLAGS and k + 1 < len(head_tokens):
|
|
2310
|
+
out.append(head_tokens[k + 1])
|
|
2311
|
+
if body.strip():
|
|
2312
|
+
out.append(body)
|
|
2313
|
+
return out
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
def _gate_one(segment: str, depth: int):
|
|
2317
|
+
"""本物の1コマンドの関門。返り (種別, 理由) / None。"""
|
|
2318
|
+
head, _, body = segment.partition('\n')
|
|
2319
|
+
toks = tokens_of(head)
|
|
2320
|
+
words = _tool_words(toks)
|
|
2321
|
+
if not words:
|
|
2322
|
+
return None
|
|
2323
|
+
v = words[0]
|
|
2324
|
+
if v in _SQL_TEXT_ONLY_VERBS:
|
|
2325
|
+
return None # 文字を扱うだけ。引数・本文に何が書かれていても実行されない。
|
|
2326
|
+
if v in _SHELL_RUNNERS:
|
|
2327
|
+
if depth >= _UNWRAP_MAX_DEPTH:
|
|
2328
|
+
return None
|
|
2329
|
+
for inner in _shell_payloads(toks, body):
|
|
2330
|
+
hit = _gate_command(inner, depth + 1)
|
|
2331
|
+
if hit:
|
|
2332
|
+
return hit
|
|
2333
|
+
return None
|
|
2334
|
+
hit = _gate_release(words, toks)
|
|
2335
|
+
if hit:
|
|
2336
|
+
return GATE_RELEASE, hit
|
|
2337
|
+
hit = _gate_db(words, toks, segment)
|
|
2338
|
+
if hit:
|
|
2339
|
+
return GATE_DB, hit
|
|
2340
|
+
return None
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def _gate_command(command: str, depth: int = 0):
|
|
2344
|
+
"""コマンド全体の関門(最初に当たったもの)。返り (種別, 理由) / None。"""
|
|
2345
|
+
for seg in split_commands(command):
|
|
2346
|
+
hit = _gate_one(seg, depth)
|
|
2347
|
+
if hit:
|
|
2348
|
+
return hit
|
|
2349
|
+
return None
|
|
2350
|
+
|
|
2351
|
+
|
|
1649
2352
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
1650
2353
|
# 入口: Bash
|
|
1651
2354
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
@@ -1664,7 +2367,8 @@ def decide_bash(command: str, cwd=None):
|
|
|
1664
2367
|
if hit:
|
|
1665
2368
|
return _r(DENY, f'danger: {hit}')
|
|
1666
2369
|
|
|
1667
|
-
|
|
2370
|
+
# ★機械的な区切りに、本物の区切りを足して測る(便 2.5・`grep -n "x|y" .env` の動詞を取り戻す)。
|
|
2371
|
+
segments = _real_segments(command)
|
|
1668
2372
|
|
|
1669
2373
|
# ---- 1) 危険(セグメントごと・対象と作用) ----
|
|
1670
2374
|
for seg in segments:
|
|
@@ -1688,6 +2392,11 @@ def decide_bash(command: str, cwd=None):
|
|
|
1688
2392
|
if hit:
|
|
1689
2393
|
return _r(DENY, f'danger: {hit}')
|
|
1690
2394
|
|
|
2395
|
+
# ---- 1c') 番人の設定への書き込み(便 2.5・リダイレクト・tee・cp・mv・sed -i ほか) ----
|
|
2396
|
+
hit = _guard_write_scan(command, cwd)
|
|
2397
|
+
if hit:
|
|
2398
|
+
return _r(DENY, f'danger: {hit}')
|
|
2399
|
+
|
|
1691
2400
|
# ---- 1d) 抜け道(便 fc22bc34・main 直 push・保護の迂回と変更・秘密の値・検査の省略) ----
|
|
1692
2401
|
hit = _bypass_scan(command, cwd)
|
|
1693
2402
|
if hit:
|
|
@@ -1698,17 +2407,11 @@ def decide_bash(command: str, cwd=None):
|
|
|
1698
2407
|
if hit:
|
|
1699
2408
|
return _prod_db_change_result(hit)
|
|
1700
2409
|
|
|
1701
|
-
# ---- 2)
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
hit = _gate_release(seg_low, v, toks)
|
|
1707
|
-
if hit:
|
|
1708
|
-
return _r(GATE, f'gate(release): {hit}', GATE_RELEASE)
|
|
1709
|
-
hit = _gate_db(seg_low, v, toks, seg)
|
|
1710
|
-
if hit:
|
|
1711
|
-
return _r(GATE, f'gate(db): {hit}', GATE_DB)
|
|
2410
|
+
# ---- 2) 関門(★本物の区切りと、実際に動く道具で見る・便 ad6b5db9) ----
|
|
2411
|
+
hit = _gate_command(command)
|
|
2412
|
+
if hit:
|
|
2413
|
+
gate, reason = hit
|
|
2414
|
+
return _r(GATE, f'gate({gate}): {reason}', gate)
|
|
1712
2415
|
|
|
1713
2416
|
# ---- 3) それ以外は自走 ----
|
|
1714
2417
|
return _r(ALLOW, 'no gate, no danger')
|
|
@@ -1752,6 +2455,11 @@ def decide_write(file_path: str, cwd=None):
|
|
|
1752
2455
|
comps = [c for c in path.split('/') if c]
|
|
1753
2456
|
if '.tyhld' in comps:
|
|
1754
2457
|
return _r(DENY, 'danger: write to guard directory (.tyhld)')
|
|
2458
|
+
# ★Bash と同じ場所の一覧で守る(ホーム直下の隠しフォルダの hooks.json・config.toml・
|
|
2459
|
+
# ~/.config/systemd・便 2.5)。
|
|
2460
|
+
gr = guard_config_reason(path)
|
|
2461
|
+
if gr:
|
|
2462
|
+
return _r(DENY, f'danger: write to {gr}')
|
|
1755
2463
|
return _r(ALLOW, 'no gate, no danger')
|
|
1756
2464
|
|
|
1757
2465
|
|