@tyhld/conductor 0.12.18 → 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
CHANGED
package/scripts/hooks/README.md
CHANGED
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
**関門でない確認(3ボタンのカード)は出しません。**
|
|
8
8
|
関門でない確認は**すべてその場で「はい」**として通し、この機の記録に「**聞かずに通した**」を1行残します。
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
★**関門(本番確認/DB実行)だけは、自走モードでも人に聞きます**
|
|
11
11
|
(便 918ce835・えふさん確定 2026-09-17「本番操作は人承認へ戻す」)。
|
|
12
|
+
★main 直変更と、本番 DB を直接変える道具は、関門ではなく **`deny`** です(便 fc22bc34・1-2節)。
|
|
12
13
|
2026-09-06〜09-17 は関門も聞かずに通していました(便 e6c5658a の実測で、関門の判定21件が確認なしで実行)。
|
|
13
14
|
★2026-09-18 から、関門は**入口の番人が管制に聞いて、答えが返るまで実行させません**。
|
|
14
15
|
auto モードでも同じです(便 ab6e36ca・4節)。
|
|
15
16
|
|
|
16
17
|
| | 自走モード(既定) | 従来(`CC2_AUTORUN=0`) |
|
|
17
18
|
| --- | --- | --- |
|
|
18
|
-
|
|
|
19
|
+
| 関門(本番確認/DB実行) | ★**管制の2択(はい/いいえ)で人が判断** | 同左 |
|
|
19
20
|
| そのほかの確認(`cd` / `npx` / 削除 など) | **聞かずに通す**+この機の記録に1行 | 管制の3ボタンで人が判断 |
|
|
20
21
|
| **危険**(持ち出し・秘密・番人の設定への書き込み・`gh pr merge --admin`) | **`deny`** | 同左 |
|
|
22
|
+
| **抜け道**(main 直 push・API での合流・保護の変更・秘密の値の設定・リポの削除・検査の省略・本番 DB の直接変更) | **`deny`**(便 fc22bc34・1-2節) | 同左 |
|
|
21
23
|
| 管制へつながらないとき | 関門は **`deny`**(フェイルクローズ)。それ以外の職人の作業は止まりません | 関門は `deny`(フェイルクローズ) |
|
|
22
24
|
| PR の合流(`gh pr merge --auto` など・鉄則7) | **聞かずに通す**(関門ではない・ADR-007) | 同左 |
|
|
23
25
|
|
|
@@ -57,28 +59,53 @@ CC2_AUTORUN=0
|
|
|
57
59
|
|
|
58
60
|
| 順番 | 何に当たるか | どうなるか |
|
|
59
61
|
| --- | --- | --- |
|
|
60
|
-
| 1 | **危険**(本番破壊・force push
|
|
61
|
-
| 2 |
|
|
62
|
+
| 1 | **危険**(本番破壊・force push・データ消失・認証情報の露出/持ち出し)+★**抜け道**(1-2節) | `deny`(実行させない) |
|
|
63
|
+
| 2 | **関門**(本番確認/DB実行) | ★入口の番人が**その場で管制の2択で聞き、答えが出るまで待つ**。「はい」→ `allow`/「いいえ」・答えが来ない・聞けない → **`deny`**(便 ab6e36ca・4節)。自走モードでも・auto モードでも同じ |
|
|
62
64
|
| 3 | それ以外 | `allow`(自走) |
|
|
63
65
|
|
|
64
66
|
★**新しいコマンドは必ず 3 に落ちます**。これが「将来出てくる道具が自動的に正しい側へ行く」形です。
|
|
65
67
|
|
|
66
68
|
### ★PRのマージは関門ではありません(ADR-007)
|
|
67
69
|
|
|
68
|
-
`gh pr merge
|
|
70
|
+
`gh pr merge`(`--auto`・`--merge`・`--squash`・`--rebase`・`--delete-branch`)は**関門から外し、職人が自走できます**。
|
|
69
71
|
マージを関門にすると、職人は毎回**最後の1手で人を待って止まる**ため、「指示を出したら終わりまで進む」が
|
|
70
72
|
成立しませんでした。マージはPRという記録が残り、レビューとCIの結果が付いた上での操作です。
|
|
71
73
|
|
|
72
|
-
**外したのはマージだけ**です。`git push origin main` のような**本番ブランチへの直接 push**
|
|
73
|
-
|
|
74
|
-
関門1の呼び名も「マージ」から「**main直変更**」へ変わりました(種別の字面 `merge` は管制の表示・
|
|
75
|
-
集計が使っているのでそのままです)。
|
|
74
|
+
**外したのはマージだけ**です。`git push origin main` のような**本番ブランチへの直接 push** は、
|
|
75
|
+
便 fc22bc34 で関門から **`deny`** へ移しました(1-2節)。★API での合流(`gh api …/pulls/N/merge` の書き込み)も `deny` です。
|
|
76
76
|
|
|
77
77
|
★**保護を迂回する合流 `gh pr merge --admin` は `deny`** です(便 918ce835・えふさん確定 2026-09-17)。
|
|
78
78
|
branch protection は「CI が通っていない物を main に入れない」歯止めで、`--admin` はそれを外します。
|
|
79
79
|
止めるのは動詞が `gh` で `pr merge` の後ろに `--admin` があるときだけで、PR の本文やコミットの説明に
|
|
80
80
|
字面が出るだけの操作は止めません。本番の DDL を含む PR の合流も、鉄則7の自動合流のままです(本番操作の承認とは分けます)。
|
|
81
81
|
|
|
82
|
+
## 1-2. ★抜け道は `deny`(便 fc22bc34・えふさん確定 2026-09-18)
|
|
83
|
+
|
|
84
|
+
考え方:**ALLOW**=普通の開発・検索・試験・練習環境(本番を変えない)/**ASK**=外部への送信・公開など、
|
|
85
|
+
正当な仕事だが実世界に影響する操作/**DENY**=職人が行う必要が無い抜け道。ここは DENY の側です。
|
|
86
|
+
|
|
87
|
+
| 抜け道 | 止める形(`policy.py`) |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| main など保護ブランチへの直接 push | `git push <remote> main`・`HEAD:master`・`--all`・`--mirror`。★書き込む先を書かない形(`git push`・`git push origin`・`git push origin HEAD`)は、**今のブランチと push 先(`@{push}`)を git に確かめ**、保護ブランチなら止める。確かめられない(切り離された HEAD・git リポでない・作業場所が変数)ときも止める(`_main_push_danger`) |
|
|
90
|
+
| 保護の迂回 | `gh pr merge --admin`・API での合流(`gh api` / `curl` で `pulls/N/merge` を PUT/POST・GraphQL の `mergePullRequest`) |
|
|
91
|
+
| 保護の変更(強める向きも) | ブランチの保護・ruleset・必須の検査・環境(`environments/<名前>`)・デプロイの保護(つなぎ先の `update_project_deployment_protection`)への書き込み |
|
|
92
|
+
| 秘密の値の設定・削除 | `vercel env add/rm`・`supabase secrets set/unset`・`wrangler secret put/delete`・`gh variable set/delete`・`fly secrets`・`heroku config:set`・`netlify env:set`、API の `actions/secrets`・`variables` への書き込み |
|
|
93
|
+
| リポの削除 | `gh repo delete`・API の `DELETE repos/<o>/<r>` |
|
|
94
|
+
| 検査・安全設定の省略 | `claude --dangerously-skip-permissions`・`--permission-mode bypassPermissions`・`git … --no-verify`・`git commit -n`・`git -c core.hooksPath=…`・コミットの説明や合流の説明に入った `[skip ci]` 類(GitHub が本当に CI を飛ばす) |
|
|
95
|
+
| 本番 DB の直接変更 | `psql`・`dropdb`・`prisma db push / migrate deploy / migrate reset`・`supabase db push / migration up`・`drizzle-kit push`・`npm/pnpm/yarn run migrate…`・REST の `rpc/exec_sql`(今の設定の deny と揃えた) |
|
|
96
|
+
|
|
97
|
+
★**字面だけでは止めません**。どれも「実際に動く道具の語の並び」(`_tool_words`)で見ます。PR の本文やコミットの説明に
|
|
98
|
+
「--admin」「skip ci」と書くだけの操作は通ります(`[skip ci]` 類は、コミットの説明に入った時点で GitHub が本当に CI を飛ばすので止めます)。
|
|
99
|
+
★`bash -c "…"` などの包みの中も同じ物差しで測り直します。
|
|
100
|
+
|
|
101
|
+
### ★本番 DB の直接変更は、あとで ASK へ移せる形
|
|
102
|
+
|
|
103
|
+
判定は `_prod_db_change` の1か所、決定は `PROD_DB_CHANGE_DECISION` の1か所(いまは `DENY`)。
|
|
104
|
+
「職人が本番の鍵を持たない+人が承認した専用の経路」が完成したら、この定数を `GATE` に差し替えるだけで、
|
|
105
|
+
同じ判定が関門 db(管制の2択)として返ります(`test_bypass_deny.py` の (7) で差し替えを確かめています)。
|
|
106
|
+
★差し替えるときは、ホームと各現場の設定の deny(`Bash(psql:*)` など)も人の手番で外してください。
|
|
107
|
+
外さないと、番人が「はい」を受けても設定の deny で止まります(便 df4accf8 の重大発見2)。
|
|
108
|
+
|
|
82
109
|
## 2. ファイルの役割
|
|
83
110
|
|
|
84
111
|
| ファイル | 役割 |
|
|
@@ -90,6 +117,7 @@ branch protection は「CI が通っていない物を main に入れない」
|
|
|
90
117
|
| `hook_datalink.py` | フックと管制のやり取り(HTTP)。**製品固有の前提を持たない=切り出し可能** |
|
|
91
118
|
| `summary_ja.py` | 確認カードに出す**日本語1行**の組み立て |
|
|
92
119
|
| `test_policy.py` | 単体テスト(297件)。`python3 scripts/hooks/test_policy.py` |
|
|
120
|
+
| `test_bypass_deny.py` | 抜け道の `deny` と、止めてはいけない操作の試験(便 fc22bc34)。`python3 scripts/hooks/test_bypass_deny.py` |
|
|
93
121
|
| (`../check_permission_rules.py`) | 現場の許可設定に残った「番人と食い違う行」を洗い出す検査(読むだけ・ADR-009) |
|
|
94
122
|
| `test_hook_datalink.py` | 連絡線の単体テスト。`python3 scripts/hooks/test_hook_datalink.py` |
|
|
95
123
|
| `settings.sample.json` | フック登録の見本(★人間が `~/.claude/settings.json` へ反映する) |
|
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'))
|
|
@@ -55,12 +55,31 @@ def check_true(label, got):
|
|
|
55
55
|
CFG = {'url': 'https://example.invalid', 'token': 'super-secret-token-value'}
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def make_work_repo(branch):
|
|
59
|
+
"""作業ブランチに居る本物の git リポを使い捨ての場所に作る(便 fc22bc34)。
|
|
60
|
+
★書き込む先を書かない push(`git push origin HEAD`)は、番人が今のブランチを git に確かめる。"""
|
|
61
|
+
import atexit
|
|
62
|
+
import shutil
|
|
63
|
+
import subprocess
|
|
64
|
+
d = tempfile.mkdtemp(prefix='guard-work-')
|
|
65
|
+
atexit.register(shutil.rmtree, d, True)
|
|
66
|
+
g = ['git', '-c', 'user.name=t', '-c', 'user.email=t@example.invalid', '-c', 'commit.gpgsign=false']
|
|
67
|
+
for args in (['init', '-q'], ['commit', '-q', '--allow-empty', '-m', 'init'],
|
|
68
|
+
['switch', '-q', '-c', branch]):
|
|
69
|
+
subprocess.run(g + args, cwd=d, check=True, capture_output=True)
|
|
70
|
+
return d
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
WORK_REPO = make_work_repo('feat/gate-sample')
|
|
74
|
+
|
|
75
|
+
|
|
58
76
|
def event(command='npx tsc --noEmit', tool='Bash'):
|
|
59
77
|
# ★既定は関門でない操作(型の確認)。関門は自走でも人に聞くので、自走の見本には使わない。
|
|
78
|
+
# ★作業場所は、作業ブランチに居る本物の git リポ(ふつうの便の形)。
|
|
60
79
|
return {
|
|
61
80
|
'tool_name': tool,
|
|
62
81
|
'tool_input': {'command': command, 'description': '型を確かめる'},
|
|
63
|
-
'cwd':
|
|
82
|
+
'cwd': WORK_REPO,
|
|
64
83
|
'session_id': 's1',
|
|
65
84
|
}
|
|
66
85
|
|
|
@@ -246,7 +265,7 @@ def pre(out):
|
|
|
246
265
|
check('自走: 関門でない操作は管制に聞けなくても allow',
|
|
247
266
|
pre(cc2_guard.build_output(event('npm run build'),
|
|
248
267
|
datalink=FakeDatalink(reachable=False))), 'allow')
|
|
249
|
-
ev = event('
|
|
268
|
+
ev = event('npm publish')
|
|
250
269
|
check('★自走: 関門は管制に聞けないなら止める(便 918ce835)',
|
|
251
270
|
pre(cc2_guard.build_output(ev, datalink=FakeDatalink(reachable=False))), 'deny')
|
|
252
271
|
check('従来: 管制に聞けないなら止める(フェイルクローズは健在)',
|
|
@@ -260,10 +279,11 @@ check('★自走でも危険は PreToolUse で止まる', pre(danger), 'deny')
|
|
|
260
279
|
print('\n── (h) ★便 918ce835: 本番操作(関門)は自走でも人の承認に回す ' + '─' * 10)
|
|
261
280
|
GATE_SAMPLES = [
|
|
262
281
|
# ★危険語を含まない見本(番人の deny に当たらない書き方)。
|
|
263
|
-
|
|
282
|
+
# ★main 直 push と本番 DB を直接変える道具(psql 等)は便 fc22bc34 で deny へ移した
|
|
283
|
+
# (test_bypass_deny.py)。ここは今も関門の操作だけ。
|
|
264
284
|
('本番へ反映(関門release)', 'npx vercel deploy --prod', 'release'),
|
|
265
285
|
('パッケージ公開(関門release)', 'npm publish', 'release'),
|
|
266
|
-
('データベース(関門db)', '
|
|
286
|
+
('データベース(関門db)', 'createdb newdb', 'db'),
|
|
267
287
|
('台本の中の SQL の字面(関門db)', 'python3 apply.py --mode grant', 'db'),
|
|
268
288
|
]
|
|
269
289
|
ROUTINE = [
|
|
@@ -327,16 +347,16 @@ for label, cmd, gate in GATE_SAMPLES:
|
|
|
327
347
|
check(f'② {label}: 人の「いいえ」で deny', behavior(prh.decide(event(cmd), datalink=fake)), 'deny')
|
|
328
348
|
# 関門の種別がカードの文言に渡っている(管制の表示・集計で使う)。
|
|
329
349
|
fake = FakeDatalink()
|
|
330
|
-
prh.decide(event('
|
|
331
|
-
check('②
|
|
350
|
+
prh.decide(event('npm publish'), datalink=fake)
|
|
351
|
+
check('② 関門のカードは本番への反映として説明する',
|
|
332
352
|
bool(fake.payloads)
|
|
333
|
-
and fake.payloads[0]['summary'].startswith('
|
|
353
|
+
and fake.payloads[0]['summary'].startswith('【お客さまの確認:本番へ反映】'), True)
|
|
334
354
|
# Codex: PreToolUse は従来どおり委譲(ask の受け付けは未確認)→ 確認の番人で2択に回す。
|
|
335
355
|
check('② Codex: PreToolUse の関門は委譲(Codex の承認の流れへ渡す)',
|
|
336
|
-
cc2_guard.build_output(event('
|
|
356
|
+
cc2_guard.build_output(event('npm publish'),
|
|
337
357
|
datalink=FakeDatalink(reachable=True), codex=True), None)
|
|
338
358
|
fake = FakeDatalink(answer='allow')
|
|
339
|
-
out = prh.decide(event('
|
|
359
|
+
out = prh.decide(event('npm publish'), datalink=fake, codex=True)
|
|
340
360
|
check('② Codex: 確認の番人は自走でも管制へ聞く', fake.asked >= 1, True)
|
|
341
361
|
check('② Codex: 「はい」でも updatedInput を添えない',
|
|
342
362
|
'updatedInput' in (out or {}).get('hookSpecificOutput', {}).get('decision', {}), False)
|
|
@@ -347,7 +367,7 @@ for label, cmd, _gate in GATE_SAMPLES:
|
|
|
347
367
|
pre(cc2_guard.build_output(event(cmd), datalink=FakeDatalink(reachable=False))), 'deny')
|
|
348
368
|
check(f'③ {label}: 管制が未設定なら deny',
|
|
349
369
|
pre(cc2_guard.build_output(event(cmd), datalink=FakeDatalink(cfg=None))), 'deny')
|
|
350
|
-
cmd = '
|
|
370
|
+
cmd = 'npm publish'
|
|
351
371
|
check('③ 確認の番人: 管制が未設定なら deny',
|
|
352
372
|
behavior(prh.decide(event(cmd), datalink=FakeDatalink(cfg=None))), 'deny')
|
|
353
373
|
check('③ 確認の番人: 登録できないなら deny',
|
|
@@ -425,20 +445,20 @@ for label, cmd in ROUTINE[:3]:
|
|
|
425
445
|
|
|
426
446
|
print(' ⑦ 待ち時間の上限(Claude Code の上限の内側・カードの期限とそろえる)')
|
|
427
447
|
fake = FakeDatalink(answer='allow')
|
|
428
|
-
cc2_guard.build_output(event('
|
|
448
|
+
cc2_guard.build_output(event('npm publish'), datalink=fake)
|
|
429
449
|
check('⑦ 人の答えを待つ上限は 480 秒', fake.waited, 480)
|
|
430
450
|
|
|
431
451
|
print(' ⑧ 同じ操作の答えを使い回さない(1回の実行ごとに新しいカード)')
|
|
432
452
|
fake = FakeDatalink(answer='allow')
|
|
433
453
|
for _ in range(2):
|
|
434
|
-
cc2_guard.build_output(event('
|
|
454
|
+
cc2_guard.build_output(event('npm publish'), datalink=fake)
|
|
435
455
|
keys = [p.get('requestKey') for p in fake.payloads]
|
|
436
456
|
check('⑧ 同じ操作を2回 → カードも2枚', len(keys), 2)
|
|
437
457
|
check('⑧ 鍵は毎回ちがう(管制に前の答えでまとめさせない)', len(set(keys)) == 2 and None not in keys, True)
|
|
438
458
|
check('⑧ 鍵は会話と関門の印つき', all(k.startswith('hook:s1:gate:') for k in keys if k), True)
|
|
439
459
|
stale = FakeDatalink(pre_answer='allow', answer=None)
|
|
440
460
|
check('⑧ ★登録の時点で付いてきた「はい」は使わない(答えが来なければ deny)',
|
|
441
|
-
pre(cc2_guard.build_output(event('
|
|
461
|
+
pre(cc2_guard.build_output(event('npm publish'), datalink=stale)), 'deny')
|
|
442
462
|
check('⑦ カードの期限(waitMs)も 480 秒にそろえる',
|
|
443
463
|
(fake.payloads[0] if fake.payloads else {}).get('waitMs'), 480_000)
|
|
444
464
|
check('⑦ 待つ上限 < 最後の歯止め < Claude Code の上限',
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""便1「絶対に通してはいけない抜け道」を閉じる(DENY を強める・便 fc22bc34)の試験。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_bypass_deny.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【えふさん確定 2026-09-18】
|
|
8
|
+
ALLOW 普通の開発・検索・試験・練習環境。本番を変えない。
|
|
9
|
+
ASK 外部への送信・公開など、正当な仕事だが実世界に影響する操作。
|
|
10
|
+
DENY main 直接 push・本番 DB 直接変更・秘密値の操作・保護変更・検査省略・迂回による保護回避。
|
|
11
|
+
|
|
12
|
+
【何を守っているか】
|
|
13
|
+
(1) main など保護ブランチへの直接 push は DENY(書き込む先を書かない push は今のブランチを確かめる。
|
|
14
|
+
確かめられないときは DENY)
|
|
15
|
+
(2) 保護の迂回=gh pr merge --admin・API での合流(pulls/N/merge・GraphQL の mergePullRequest)
|
|
16
|
+
(3) 保護の変更=ブランチの保護・ruleset・必須の検査・環境の保護・デプロイの保護(強める向きも)
|
|
17
|
+
(4) 秘密の値の設定・削除
|
|
18
|
+
(5) リポの削除
|
|
19
|
+
(6) 検査・安全設定の省略(--dangerously-skip-permissions・--no-verify・[skip ci] 類 ほか)
|
|
20
|
+
(7) 本番 DB の直接変更(設定の deny と揃える)。★判定1か所+決定1か所=あとで ASK へ移せる
|
|
21
|
+
(9) 止めてはいけないもの(普通の合流・作業ブランチへの push・PR・試験・報告の経路・字面だけの文章)
|
|
22
|
+
|
|
23
|
+
★見本の危険語は部品から組み立てる(番人の見本に危険語をそのまま書かない)。
|
|
24
|
+
"""
|
|
25
|
+
import atexit
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
|
|
32
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
33
|
+
|
|
34
|
+
import cc2_guard # noqa: E402
|
|
35
|
+
import policy # noqa: E402
|
|
36
|
+
|
|
37
|
+
failures = []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check(label, got, want):
|
|
41
|
+
ok = got == want
|
|
42
|
+
if not ok:
|
|
43
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
44
|
+
print(f'{"OK " if ok else "NG "} {label:70} {str(got)[:36]}')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── 部品(危険語を直接書かない) ──
|
|
48
|
+
PUSH = 'pu' + 'sh'
|
|
49
|
+
MAIN = 'ma' + 'in'
|
|
50
|
+
MASTER = 'mas' + 'ter'
|
|
51
|
+
PROD = 'produc' + 'tion'
|
|
52
|
+
ADMIN = '--ad' + 'min'
|
|
53
|
+
MERGE = 'mer' + 'ge'
|
|
54
|
+
SKIPCI = '[sk' + 'ip ci]'
|
|
55
|
+
NOVERIFY = '--no-' + 'verify'
|
|
56
|
+
DSP = '--dangerously-' + 'skip-permissions'
|
|
57
|
+
BYPASS = 'bypass' + 'Permissions'
|
|
58
|
+
SECRETS = 'sec' + 'rets'
|
|
59
|
+
SECRET = 'sec' + 'ret'
|
|
60
|
+
DB_PUSH = 'db ' + PUSH
|
|
61
|
+
MIGRATE = 'mig' + 'rate'
|
|
62
|
+
PSQL = 'ps' + 'ql'
|
|
63
|
+
DROPDB = 'drop' + 'db'
|
|
64
|
+
PROTECTION = 'protec' + 'tion'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def make_repo(branch=None, detached=False, upstream=None):
|
|
68
|
+
"""使い捨ての本物の git リポ。branch に居る/切り離された HEAD。"""
|
|
69
|
+
d = tempfile.mkdtemp(prefix='guard-bypass-')
|
|
70
|
+
atexit.register(shutil.rmtree, d, True)
|
|
71
|
+
g = ['git', '-c', 'user.name=t', '-c', 'user.email=t@example.invalid',
|
|
72
|
+
'-c', 'commit.gpgsign=false']
|
|
73
|
+
subprocess.run(g + ['init', '-q', '-b', MAIN], cwd=d, check=True, capture_output=True)
|
|
74
|
+
subprocess.run(g + ['commit', '-q', '--allow-empty', '-m', 'init'], cwd=d, check=True,
|
|
75
|
+
capture_output=True)
|
|
76
|
+
if branch:
|
|
77
|
+
subprocess.run(g + ['switch', '-q', '-c', branch], cwd=d, check=True, capture_output=True)
|
|
78
|
+
if detached:
|
|
79
|
+
subprocess.run(g + ['switch', '-q', '--detach'], cwd=d, check=True, capture_output=True)
|
|
80
|
+
if upstream:
|
|
81
|
+
# 作業ブランチの push 先を main に向けた形(push.default=upstream)。
|
|
82
|
+
bare = tempfile.mkdtemp(prefix='guard-bypass-remote-')
|
|
83
|
+
atexit.register(shutil.rmtree, bare, True)
|
|
84
|
+
subprocess.run(['git', 'init', '-q', '--bare', bare], check=True, capture_output=True)
|
|
85
|
+
for args in (['remote', 'add', 'origin', bare],
|
|
86
|
+
['config', 'push.default', 'upstream'],
|
|
87
|
+
['config', f'branch.{branch}.remote', 'origin'],
|
|
88
|
+
['config', f'branch.{branch}.merge', f'refs/heads/{upstream}'],
|
|
89
|
+
['update-ref', f'refs/remotes/origin/{upstream}', 'HEAD']):
|
|
90
|
+
subprocess.run(g + args, cwd=d, check=True, capture_output=True)
|
|
91
|
+
return d
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
REPO_MAIN = make_repo()
|
|
95
|
+
REPO_FEAT = make_repo('feat/gate-sample')
|
|
96
|
+
REPO_DETACHED = make_repo(detached=True)
|
|
97
|
+
REPO_FEAT_TO_MAIN = make_repo('feat/sneaky', upstream=MAIN)
|
|
98
|
+
NOT_A_REPO = tempfile.mkdtemp(prefix='guard-bypass-plain-')
|
|
99
|
+
atexit.register(shutil.rmtree, NOT_A_REPO, True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def decide(cmd, cwd=REPO_FEAT):
|
|
103
|
+
return policy.decide_bash(cmd, cwd)['decision']
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def expect(label, cmd, want, cwd=REPO_FEAT):
|
|
107
|
+
check(f'{label}: {cmd[:46]}', decide(cmd, cwd), want)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
print('── (1) main など保護ブランチへの直接 push は DENY ' + '─' * 20)
|
|
111
|
+
for cmd in [f'git {PUSH} origin {MAIN}', f'git {PUSH} -u origin {MAIN}',
|
|
112
|
+
f'git {PUSH} origin HEAD:{MASTER}', f'git {PUSH} origin {PROD}',
|
|
113
|
+
f'git {PUSH} origin feat/x:refs/heads/{MAIN}', f'git {PUSH} --all origin',
|
|
114
|
+
f'git {PUSH} --mirror origin', f'bash -c "git {PUSH} origin {MAIN}"',
|
|
115
|
+
f'cd /srv/app && git {PUSH} origin {MAIN}']:
|
|
116
|
+
expect('(1) 名指し', cmd, policy.DENY)
|
|
117
|
+
# 書き込む先を書かない push は、今のブランチを確かめる。
|
|
118
|
+
for cmd in [f'git {PUSH}', f'git {PUSH} origin', f'git {PUSH} origin HEAD', f'git {PUSH} -u origin HEAD']:
|
|
119
|
+
expect('(1) main に居る', cmd, policy.DENY, REPO_MAIN)
|
|
120
|
+
expect('(1) 作業ブランチに居る=allow', cmd, policy.ALLOW, REPO_FEAT)
|
|
121
|
+
expect('(1) 切り離された HEAD=確かめられない', cmd, policy.DENY, REPO_DETACHED)
|
|
122
|
+
expect('(1) git リポでない=確かめられない', cmd, policy.DENY, NOT_A_REPO)
|
|
123
|
+
expect('(1) push 先が main の作業ブランチ', f'git {PUSH}', policy.DENY, REPO_FEAT_TO_MAIN)
|
|
124
|
+
expect('(1) cd で main のリポへ移ってから', f'cd {REPO_MAIN} && git {PUSH}', policy.DENY, REPO_FEAT)
|
|
125
|
+
expect('(1) cd で作業ブランチのリポへ移ってから', f'cd {REPO_FEAT} && git {PUSH}', policy.ALLOW,
|
|
126
|
+
REPO_MAIN)
|
|
127
|
+
expect('(1) git -C で main のリポ', f'git -C {REPO_MAIN} {PUSH}', policy.DENY, REPO_FEAT)
|
|
128
|
+
expect('(1) 作業場所が変数', f'cd $APP_DIR && git {PUSH}', policy.DENY, REPO_FEAT)
|
|
129
|
+
expect('(1) git -C が変数', f'git -C "$APP_DIR" {PUSH} origin HEAD', policy.DENY, REPO_FEAT)
|
|
130
|
+
for cmd in [f'git {PUSH} -u origin feat/gate-sample', f'git {PUSH} origin feat/{MAIN}-fix',
|
|
131
|
+
f'git {PUSH} origin HEAD:feat/x', f'git {PUSH} --tags']:
|
|
132
|
+
expect('(1) 作業ブランチは allow', cmd, policy.ALLOW, REPO_MAIN if 'feat/' in cmd else REPO_FEAT)
|
|
133
|
+
|
|
134
|
+
print('\n── (2) 保護の迂回(--admin・API での合流) ' + '─' * 26)
|
|
135
|
+
for cmd in [f'gh pr {MERGE} 12 {ADMIN}', f'gh pr {MERGE} 151 {ADMIN} --{MERGE} --delete-branch',
|
|
136
|
+
f'gh api -X PUT repos/o/r/pulls/1/{MERGE}',
|
|
137
|
+
f'gh api --method PUT repos/o/r/pulls/1/{MERGE}',
|
|
138
|
+
f'gh api -XPUT /repos/o/r/pulls/7/{MERGE} -f {MERGE}_method=squash',
|
|
139
|
+
f'gh api repos/o/r/pulls/7/{MERGE} -f {MERGE}_method=squash',
|
|
140
|
+
f'gh api graphql -f query=\'mutation {{ {MERGE}PullRequest(input:{{pullRequestId:"x"}}) {{ clientMutationId }} }}\'',
|
|
141
|
+
f'curl -X PUT -H "Accept: application/vnd.github+json" https://api.github.com/repos/o/r/pulls/1/{MERGE}',
|
|
142
|
+
f'bash -c "gh api -X PUT repos/o/r/pulls/1/{MERGE}"']:
|
|
143
|
+
expect('(2)', cmd, policy.DENY)
|
|
144
|
+
for cmd in [f'gh api repos/o/r/pulls/1/{MERGE}', 'gh api repos/o/r/pulls/1',
|
|
145
|
+
f'gh pr {MERGE} 12 --auto --{MERGE} --delete-branch',
|
|
146
|
+
'gh api graphql -f query=\'mutation { enablePullRequestAutoMerge(input:{pullRequestId:"x"}) { clientMutationId } }\'']:
|
|
147
|
+
expect('(2) 読み取り・普通の合流は allow', cmd, policy.ALLOW)
|
|
148
|
+
|
|
149
|
+
print('\n── (3) 保護の変更(強める向きも DENY) ' + '─' * 30)
|
|
150
|
+
for cmd in [f'gh api -X PUT repos/o/r/branches/{MAIN}/{PROTECTION} --input rules.json',
|
|
151
|
+
f'gh api -X DELETE repos/o/r/branches/{MAIN}/{PROTECTION}',
|
|
152
|
+
f'gh api -X POST repos/o/r/branches/{MAIN}/{PROTECTION}/required_status_checks/contexts -f contexts[]=test',
|
|
153
|
+
f'gh api -X PATCH repos/o/r/branches/{MAIN}/{PROTECTION}/required_pull_request_reviews -F x=1',
|
|
154
|
+
'gh api -X POST repos/o/r/rulesets --input ruleset.json',
|
|
155
|
+
'gh api -X PUT repos/o/r/rulesets/5 --input ruleset.json',
|
|
156
|
+
'gh api -X DELETE orgs/o/rulesets/5',
|
|
157
|
+
f'gh api -X PUT repos/o/r/environments/{PROD}',
|
|
158
|
+
f'curl -X DELETE https://api.github.com/repos/o/r/branches/{MAIN}/{PROTECTION}']:
|
|
159
|
+
expect('(3)', cmd, policy.DENY)
|
|
160
|
+
for cmd in [f'gh api repos/o/r/branches/{MAIN}/{PROTECTION}', 'gh api repos/o/r/rulesets',
|
|
161
|
+
f'gh api repos/o/r/environments/{PROD}']:
|
|
162
|
+
expect('(3) 読み取りは allow', cmd, policy.ALLOW)
|
|
163
|
+
check('(3) つなぎ先の道具でデプロイの保護を変える',
|
|
164
|
+
policy.decide_event({'tool_name': 'mcp__plugin_vercel_vercel__update_project_deployment_' + PROTECTION,
|
|
165
|
+
'tool_input': {'projectId': 'p'}})['decision'], policy.DENY)
|
|
166
|
+
check('(3) つなぎ先の読み取りは allow',
|
|
167
|
+
policy.decide_event({'tool_name': 'mcp__plugin_vercel_vercel__get_project_deployment_' + PROTECTION,
|
|
168
|
+
'tool_input': {'projectId': 'p'}})['decision'], policy.ALLOW)
|
|
169
|
+
|
|
170
|
+
print('\n── (4) 秘密の値の設定・削除 ' + '─' * 40)
|
|
171
|
+
for cmd in ['vercel env add API_URL production', 'npx vercel env rm API_URL',
|
|
172
|
+
'pnpm dlx vercel env add API_URL', 'vercel --scope team env add API_URL',
|
|
173
|
+
f'supabase {SECRETS} set K=1', f'npx supabase {SECRETS} unset K',
|
|
174
|
+
f'wrangler {SECRET} put K', f'npx wrangler {SECRET} delete K',
|
|
175
|
+
f'wrangler versions {SECRET} put K', 'gh variable set K --body v', 'gh variable delete K',
|
|
176
|
+
f'fly {SECRETS} set K=1', f'flyctl {SECRETS} unset K', 'heroku config:set K=1',
|
|
177
|
+
'netlify env:set K v', f'gh api -X PUT repos/o/r/actions/{SECRETS}/K -f x=1',
|
|
178
|
+
'gh api -X POST repos/o/r/actions/variables -f name=K -f value=v',
|
|
179
|
+
f'gh api -X DELETE repos/o/r/environments/{PROD}/{SECRETS}/K']:
|
|
180
|
+
expect('(4)', cmd, policy.DENY)
|
|
181
|
+
for cmd in ['vercel env ls', 'gh variable list', f'wrangler {SECRET} list', f'supabase {SECRETS} list',
|
|
182
|
+
'git commit -m "docs: vercel env add の手順を書く"',
|
|
183
|
+
f'gh api repos/o/r/actions/{SECRETS}']:
|
|
184
|
+
expect('(4) 一覧・字面だけは allow', cmd, policy.ALLOW)
|
|
185
|
+
|
|
186
|
+
print('\n── (5) リポの削除 ' + '─' * 50)
|
|
187
|
+
for cmd in ['gh repo delete o/r --yes', 'gh api -X DELETE repos/o/r']:
|
|
188
|
+
expect('(5)', cmd, policy.DENY)
|
|
189
|
+
for cmd in ['gh repo view o/r', 'gh api repos/o/r', 'gh api -X DELETE repos/o/r/labels/bug']:
|
|
190
|
+
expect('(5) 読み取り・ほかの削除は allow', cmd, policy.ALLOW)
|
|
191
|
+
|
|
192
|
+
print('\n── (6) 検査・安全設定の省略 ' + '─' * 40)
|
|
193
|
+
for cmd in [f'claude {DSP} -p hello', f'claude --permission-mode {BYPASS}',
|
|
194
|
+
f'claude --permission-mode={BYPASS} -p x', f'npx @anthropic-ai/claude-code {DSP}',
|
|
195
|
+
f'git {PUSH} {NOVERIFY} origin feat/x', f'git commit {NOVERIFY} -m x',
|
|
196
|
+
'git commit -n -m x', 'git -c core.hooksPath=/dev/null commit -m x',
|
|
197
|
+
f'git commit -m "fix: 直す {SKIPCI}"', 'git commit -m "x [CI SKIP]"',
|
|
198
|
+
'git commit -m "x" -m "skip-checks: true"',
|
|
199
|
+
f'gh pr {MERGE} 3 --{MERGE} --subject "x {SKIPCI}"']:
|
|
200
|
+
expect('(6)', cmd, policy.DENY)
|
|
201
|
+
for cmd in [f'gh pr create --title x --body "{SKIPCI} は使わない"',
|
|
202
|
+
'git commit -m "docs: skip ci は使わない方針"',
|
|
203
|
+
f'git commit -m "{NOVERIFY} を禁止する"',
|
|
204
|
+
f'gh pr create --title x --body "{DSP} を禁止"',
|
|
205
|
+
'claude --permission-mode auto -p x', f'git {PUSH} -n origin feat/x', 'git log -n 5']:
|
|
206
|
+
expect('(6) 字面だけ・ほかの旗は allow', cmd, policy.ALLOW)
|
|
207
|
+
|
|
208
|
+
print('\n── (7) 本番 DB の直接変更(設定の deny と揃える) ' + '─' * 18)
|
|
209
|
+
PROD_DB = [f'{PSQL} -c "select 1"', f'{DROPDB} olddb', f'npx prisma {DB_PUSH}',
|
|
210
|
+
f'prisma {MIGRATE} deploy', f'npx prisma {MIGRATE} reset', f'supabase {DB_PUSH}',
|
|
211
|
+
f'npx supabase {MIGRATE[:-1]}ion up', f'pnpm exec drizzle-kit {PUSH}', f'npm run {MIGRATE}',
|
|
212
|
+
f'pnpm {MIGRATE}:prod', f'pnpm run {MIGRATE}', f'echo "select 1" | {PSQL}',
|
|
213
|
+
'curl -X POST https://x.example.invalid/rest/v1/rpc/exec_sql -d "{}"',
|
|
214
|
+
f'bash -c "npx prisma {DB_PUSH}"']
|
|
215
|
+
for cmd in PROD_DB:
|
|
216
|
+
expect('(7)', cmd, policy.DENY)
|
|
217
|
+
for cmd in ['npx prisma generate', 'npx prisma studio', 'npm run build']:
|
|
218
|
+
expect('(7) 本番を変えない道具は allow', cmd, policy.ALLOW)
|
|
219
|
+
# ★文字列の切れ端は本番 DB の道具と読まない(7日分の記録に実在した形・便 fc22bc34 の自己点検)。
|
|
220
|
+
# ・検索の文字の中の `…|psql`(区切られて `psql"` という語になる)
|
|
221
|
+
# ・ヒアドキュメントの中の箇条書きの行(`- prisma db push は…`)
|
|
222
|
+
for cmd in [f'grep -n "timeout|{PSQL}" notes.md',
|
|
223
|
+
f'cat <<EOF > notes.md\n- prisma {DB_PUSH} は人が行う\n- supabase {DB_PUSH} も同じ\nEOF']:
|
|
224
|
+
check(f'(7) 切れ端は DENY にしない: {cmd[:34]!r}', decide(cmd) != policy.DENY, True)
|
|
225
|
+
for cmd in ['createdb newdb', f'npx prisma {MIGRATE} dev', 'run-sql "ALTER TABLE x ADD y int"']:
|
|
226
|
+
check(f'(7) 設定の deny に無い DB 実行は関門のまま(便3で見分ける): {cmd[:30]}',
|
|
227
|
+
policy.decide_bash(cmd)['decision'], policy.GATE)
|
|
228
|
+
# ★あとで ASK へ移せる形: 決定の定数を1つ差し替えると、同じ判定が関門 db(管制の2択)になる。
|
|
229
|
+
check('(7) 決定の置き場は1か所(いまは DENY)', policy.PROD_DB_CHANGE_DECISION, policy.DENY)
|
|
230
|
+
_saved = policy.PROD_DB_CHANGE_DECISION
|
|
231
|
+
policy.PROD_DB_CHANGE_DECISION = policy.GATE
|
|
232
|
+
try:
|
|
233
|
+
for cmd in PROD_DB[:3]:
|
|
234
|
+
r = policy.decide_bash(cmd)
|
|
235
|
+
check(f'(7) 差し替えると関門 db になる: {cmd[:30]}', (r['decision'], r['gate']),
|
|
236
|
+
(policy.GATE, policy.GATE_DB))
|
|
237
|
+
finally:
|
|
238
|
+
policy.PROD_DB_CHANGE_DECISION = _saved
|
|
239
|
+
check('(7) 差し替えを戻すと DENY', decide(PROD_DB[0]), policy.DENY)
|
|
240
|
+
|
|
241
|
+
print('\n── (9) 止めてはいけないもの ' + '─' * 40)
|
|
242
|
+
ROUTINE = [
|
|
243
|
+
f'gh pr {MERGE} 12 --auto --{MERGE} --delete-branch', f'gh pr {MERGE} 12 --{MERGE} --delete-branch',
|
|
244
|
+
f'gh pr {MERGE} 12 --squash', f'gh pr {MERGE} 12 --rebase', f'gh pr {MERGE} 12 --auto --squash',
|
|
245
|
+
f'git {PUSH} -u origin feat/gate-sample', f'git {PUSH} origin HEAD',
|
|
246
|
+
'gh pr create --title x --body y', 'npm install', 'npm test', 'npx tsc --noEmit', 'npm run build',
|
|
247
|
+
'git commit -m "docs: 説明を直す"',
|
|
248
|
+
f'gh pr create --title x --body "{ADMIN} も {SKIPCI} も使わない"',
|
|
249
|
+
f'git commit -m "gh pr {MERGE} {ADMIN} を禁止する"',
|
|
250
|
+
]
|
|
251
|
+
for cmd in ROUTINE:
|
|
252
|
+
expect('(9)', cmd, policy.ALLOW)
|
|
253
|
+
for tool in ('mcp__claude_ai_ai-kana__conductor_complete', 'mcp__claude_ai_ai-kana__devlog_create_entry',
|
|
254
|
+
'mcp__claude_ai_ai-kana__devlog_recent_entries'):
|
|
255
|
+
check(f'(9) 報告の経路は allow: {tool[-28:]}',
|
|
256
|
+
policy.decide_event({'tool_name': tool, 'tool_input': {'commandId': 'x', 'report': 'r'}})['decision'],
|
|
257
|
+
policy.ALLOW)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class NoAsk:
|
|
261
|
+
"""管制の代わり。DENY は管制に聞かずに止まることを確かめる。"""
|
|
262
|
+
asked = 0
|
|
263
|
+
|
|
264
|
+
def load_config(self):
|
|
265
|
+
return {'url': 'http://x', 'token': 't'}
|
|
266
|
+
|
|
267
|
+
def register(self, cfg, payload):
|
|
268
|
+
NoAsk.asked += 1
|
|
269
|
+
return {'id': 'r', 'status': 'waiting'}
|
|
270
|
+
|
|
271
|
+
def wait_for_decision(self, cfg, request_id, max_wait_sec=None):
|
|
272
|
+
return 'allow'
|
|
273
|
+
|
|
274
|
+
def abandon(self, cfg, request_id):
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
def ping(self, cfg):
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
def record_decision(self, *a, **k):
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
print('\n── 入口の番人: DENY は管制に聞かずに止まる(人が「はい」を押しても通らない) ' + '─' * 2)
|
|
285
|
+
for cmd in [f'git {PUSH} origin {MAIN}', f'gh api -X PUT repos/o/r/pulls/1/{MERGE}', PROD_DB[0],
|
|
286
|
+
f'wrangler {SECRET} put K']:
|
|
287
|
+
out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': cmd},
|
|
288
|
+
'cwd': REPO_FEAT}, datalink=NoAsk())
|
|
289
|
+
check(f'入口 deny: {cmd[:40]}', out['hookSpecificOutput']['permissionDecision'], 'deny')
|
|
290
|
+
check('入口: 管制へ0回(カードを出さない)', NoAsk.asked, 0)
|
|
291
|
+
|
|
292
|
+
print('\n' + '=' * 72)
|
|
293
|
+
if failures:
|
|
294
|
+
print(f'NG {len(failures)} 件:')
|
|
295
|
+
for f in failures:
|
|
296
|
+
print(' -', f)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
print('すべて期待どおり')
|
|
299
|
+
sys.exit(0)
|
|
@@ -132,7 +132,9 @@ def run_guard(command, url, extra_env=None, home=None):
|
|
|
132
132
|
return out.get('permissionDecision'), out.get('permissionDecisionReason'), took
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
# 関門 db の見本(危険語を含まない・実行はしない)。★psql は便 fc22bc34 で deny へ移したので、
|
|
136
|
+
# 今も関門のまま残る「台本に SQL の字面を渡す形」を使う。
|
|
137
|
+
GATE_CMD = 'python3 apply.py --mode grant'
|
|
136
138
|
ROUTINE_CMD = 'npm test'
|
|
137
139
|
|
|
138
140
|
with tempfile.TemporaryDirectory() as home:
|
|
@@ -40,11 +40,8 @@ def check(label, got, want):
|
|
|
40
40
|
# (a) 3関門は必ず gate(=人へ)
|
|
41
41
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
42
42
|
GATE_CASES = [
|
|
43
|
-
# 関門1
|
|
44
|
-
|
|
45
|
-
('git push -u origin main', policy.GATE_MERGE),
|
|
46
|
-
('git push origin HEAD:master', policy.GATE_MERGE),
|
|
47
|
-
('git push origin production', policy.GATE_MERGE),
|
|
43
|
+
# ★旧 関門1(main など保護ブランチへの直接 push)は便 fc22bc34 で deny へ移した
|
|
44
|
+
# → test_bypass_deny.py で確かめる。PRのマージは関門ではない → (a4)。
|
|
48
45
|
# 関門2 本番確認
|
|
49
46
|
('vercel --prod', policy.GATE_RELEASE),
|
|
50
47
|
('vercel deploy --prod', policy.GATE_RELEASE),
|
|
@@ -56,14 +53,10 @@ GATE_CASES = [
|
|
|
56
53
|
('supabase functions deploy hello', policy.GATE_RELEASE),
|
|
57
54
|
('npm run deploy -- --production', policy.GATE_RELEASE),
|
|
58
55
|
# 関門3 DB実行
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
('supabase db push', policy.GATE_DB),
|
|
63
|
-
('supabase migration up', policy.GATE_DB),
|
|
64
|
-
('npx drizzle-kit push', policy.GATE_DB),
|
|
56
|
+
# ★本番 DB を直接変える道具(psql・prisma db push / migrate deploy・supabase db push /
|
|
57
|
+
# migration up・drizzle-kit push 等)は便 fc22bc34 で deny へ移した(設定の deny と揃える)
|
|
58
|
+
# → test_bypass_deny.py。ここに残るのは、それ以外の DB 実行(見分けられないので人に聞く)。
|
|
65
59
|
('createdb newdb', policy.GATE_DB),
|
|
66
|
-
('echo "CREATE TABLE x (id uuid)" | psql', policy.GATE_DB),
|
|
67
60
|
('supabase db query "GRANT SELECT ON x TO authenticated"', policy.GATE_DB),
|
|
68
61
|
('run-sql "ALTER TABLE x ENABLE ROW LEVEL SECURITY"', policy.GATE_DB),
|
|
69
62
|
('apply "CREATE POLICY p ON x"', policy.GATE_DB),
|
|
@@ -109,9 +102,8 @@ class GuardDatalink:
|
|
|
109
102
|
self.decisions.append((tool_name, decision, gate))
|
|
110
103
|
|
|
111
104
|
|
|
112
|
-
GATE_CMDS = ['
|
|
113
|
-
'
|
|
114
|
-
'psql -c "select 1"']
|
|
105
|
+
GATE_CMDS = ['vercel --prod', 'npm publish', 'createdb newdb',
|
|
106
|
+
'supabase db query "select 1"', 'run-sql "ALTER TABLE x ADD COLUMN y int"']
|
|
115
107
|
|
|
116
108
|
print('\n=== (a2) 関門は入口の番人がその場で管制に聞き、答えで allow / deny を返す(便 ab6e36ca) ===')
|
|
117
109
|
gd_ok = GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True)
|
|
@@ -138,7 +130,7 @@ for cmd in GATE_CMDS:
|
|
|
138
130
|
check(f'{cmd} → deny(管制不通)', got, 'deny')
|
|
139
131
|
|
|
140
132
|
gd_unset = GuardDatalink(None, reachable=False) # 管制未設定(load_config が None)
|
|
141
|
-
out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': '
|
|
133
|
+
out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
|
|
142
134
|
datalink=gd_unset)
|
|
143
135
|
check('管制未設定でも関門は deny', out['hookSpecificOutput']['permissionDecision'], 'deny')
|
|
144
136
|
|
|
@@ -153,11 +145,11 @@ check('危険は管制不通でも deny のまま', out['hookSpecificOutput']['p
|
|
|
153
145
|
# ★便 ab6e36ca: 以前の無効化フラグ(CC2_GATE_FAILCLOSE=0 で止めずに ask)は外した。
|
|
154
146
|
# ask は auto モードでは人の答えを待たずに実行されうる(便 c525bb2b)=止めが消える回避路になるため。
|
|
155
147
|
os.environ['CC2_GATE_FAILCLOSE'] = '0'
|
|
156
|
-
out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': '
|
|
148
|
+
out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
|
|
157
149
|
datalink=gd_unset)
|
|
158
150
|
check('★CC2_GATE_FAILCLOSE=0 でも関門は deny(回避路を作らない)',
|
|
159
151
|
out['hookSpecificOutput']['permissionDecision'], 'deny')
|
|
160
|
-
out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': '
|
|
152
|
+
out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
|
|
161
153
|
datalink=gd_ng)
|
|
162
154
|
check('★CC2_GATE_FAILCLOSE=0 でも管制不通の関門は deny',
|
|
163
155
|
out['hookSpecificOutput']['permissionDecision'], 'deny')
|
|
@@ -179,7 +171,8 @@ MERGE_ALLOW = [
|
|
|
179
171
|
'gh pr merge --auto 12',
|
|
180
172
|
'gh pr merge 3 --merge --delete-branch',
|
|
181
173
|
'gh pr merge --rebase 7',
|
|
182
|
-
|
|
174
|
+
# ★API での合流(gh api -X PUT …/pulls/N/merge)は便 fc22bc34 で deny へ移した
|
|
175
|
+
# (保護の迂回・えふさん確定 2026-09-18)→ test_bypass_deny.py。
|
|
183
176
|
'cd /srv/app && gh pr merge 12 --squash', # 複合コマンドでも自走
|
|
184
177
|
'bash -c "gh pr merge 12 --squash"', # 包んでも自走
|
|
185
178
|
'gh pr merge 12 --squash && git pull',
|
|
@@ -220,12 +213,12 @@ for _gd in (GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True),
|
|
|
220
213
|
out['hookSpecificOutput']['permissionDecision'], 'allow')
|
|
221
214
|
|
|
222
215
|
# ★マージを外しても、他の関門と危険は一切緩めていない(取り違え防止の回帰テスト)。
|
|
223
|
-
check('main 直 push
|
|
224
|
-
policy.decide_bash('git push origin main')['
|
|
216
|
+
check('main 直 push は deny(便 fc22bc34 で関門から移した)',
|
|
217
|
+
policy.decide_bash('git push origin main')['decision'], policy.DENY)
|
|
225
218
|
check('force push は deny のまま',
|
|
226
219
|
policy.decide_bash('git push --force origin main')['decision'], policy.DENY)
|
|
227
|
-
check('DB
|
|
228
|
-
policy.decide_bash('
|
|
220
|
+
check('DB実行(見分けられないもの)は関門のまま',
|
|
221
|
+
policy.decide_bash('createdb newdb')['gate'], policy.GATE_DB)
|
|
229
222
|
check('SQL DDL は関門のまま',
|
|
230
223
|
policy.decide_bash('run-sql "CREATE TABLE x (id int)"')['gate'], policy.GATE_DB)
|
|
231
224
|
check('本番反映は関門のまま',
|
|
@@ -566,12 +559,11 @@ check('枝への push は通常の文言',
|
|
|
566
559
|
summary_ja.summarize_bash('git push origin feat/main-fix'),
|
|
567
560
|
'作業内容をGitHubへ送ってよいですか')
|
|
568
561
|
# ★見出しは「関門に当たったセグメント」から作る(複合コマンドの先頭語 cd に引きずられない)。
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
'【お客さまの確認:データベースの変更】データベースの構造を変更してよいですか')
|
|
562
|
+
# ★main 直 push と本番 DB を直接変える道具は、便 fc22bc34 で関門から deny へ移した
|
|
563
|
+
# (カードは出ない)。見出しの作り方は、今も関門のままの操作で確かめる。
|
|
564
|
+
check('複合コマンドでも関門セグメントから見出し(公開)',
|
|
565
|
+
summary_ja.summarize_bash('cd /srv/app && npm publish', 'release'),
|
|
566
|
+
'【お客さまの確認:本番へ反映】部品を公開(配布)してよいですか')
|
|
575
567
|
|
|
576
568
|
|
|
577
569
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -663,7 +655,7 @@ check('保存先は session(設定を汚さない)', up[0]['destination'], '
|
|
|
663
655
|
|
|
664
656
|
# 関門には恒久許可を与えない。
|
|
665
657
|
fd = FakeDatalink('allow_always')
|
|
666
|
-
GATE_EV = {'tool_name': 'Bash', 'tool_input': {'command': '
|
|
658
|
+
GATE_EV = {'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'},
|
|
667
659
|
'cwd': '/home/fshim/projects/conductor', 'session_id': 's1'}
|
|
668
660
|
out = permission_request_hook.decide(GATE_EV, cfg={'url': 'http://x', 'token': 't'}, datalink=fd)
|
|
669
661
|
check('関門に恒久許可を与えない',
|
|
@@ -693,7 +685,7 @@ check('送る payload に choices が入る(3択)',
|
|
|
693
685
|
|
|
694
686
|
# ★フェイルクローズ: 関門は「聞けない」とき deny を返す(委譲=素通りにしない)。
|
|
695
687
|
print('\n=== (g2) PermissionRequest フックのフェイルクローズ(関門のみ) ===')
|
|
696
|
-
GATE_PR_EV = {'tool_name': 'Bash', 'tool_input': {'command': '
|
|
688
|
+
GATE_PR_EV = {'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'},
|
|
697
689
|
'cwd': '/home/fshim/projects/conductor', 'session_id': 's1'}
|
|
698
690
|
|
|
699
691
|
|