@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.18",
3
+ "version": "0.12.19",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,17 +7,19 @@
7
7
  **関門でない確認(3ボタンのカード)は出しません。**
8
8
  関門でない確認は**すべてその場で「はい」**として通し、この機の記録に「**聞かずに通した**」を1行残します。
9
9
 
10
- ★**3関門(main直変更/本番確認/DB実行)だけは、自走モードでも人に聞きます**
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
- | 3関門(main直変更/本番確認/DB実行) | ★**管制の2択(はい/いいえ)で人が判断** | 同左 |
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・データ消失・認証情報の露出/持ち出し) | `deny`(実行させない) |
61
- | 2 | **3関門**(main直変更/本番確認/DB実行) | ★入口の番人が**その場で管制の2択で聞き、答えが出るまで待つ**。「はい」→ `allow`/「いいえ」・答えが来ない・聞けない → **`deny`**(便 ab6e36ca・4節)。自走モードでも・auto モードでも同じ |
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`(および `gh api …/pulls/N/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
- 関門のまま残ります(PRもレビューもCIも通らずに本番の元が変わるため、性質がまったく違います)。
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` へ反映する) |
@@ -10,11 +10,15 @@
10
10
 
11
11
  【この版の判定(上から順に1つだけ当たる)】
12
12
  1. 危険 → deny … 本番破壊・force push・データ消失・認証情報の露出/持ち出し
13
- 2. 3関門 → gate … main直変更 / 本番確認 / DB実行(人が必ず判断する)
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完全自動化)。詳しくは _gate_merge の説明。
17
- ただし保護を迂回する `gh pr merge --admin` は危険(deny)。詳しくは _admin_merge_danger の説明。
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 の中身は「PRのマージ」から「main への直接 push」へ縮んだ(_gate_merge 参照)。
42
- # 種別の字面 'merge' は管制の表示・集計が使っているので変えない。
43
- GATE_MERGE = 'merge' # 関門1 本番ブランチの直接変更
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': '/home/x/projects/demo',
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('git push origin main')
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
- ('本番の元へ直接(関門merge)', 'git push origin main', 'merge'),
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)', 'psql -c "select 1"', '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('git push origin main'), datalink=fake)
331
- check('② 関門のカードは本番の元の変更として説明する',
350
+ prh.decide(event('npm publish'), datalink=fake)
351
+ check('② 関門のカードは本番への反映として説明する',
332
352
  bool(fake.payloads)
333
- and fake.payloads[0]['summary'].startswith('【お客さまの確認:本番の元を直接変更】'), True)
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('git push origin main'),
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('git push origin main'), datalink=fake, codex=True)
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 = 'git push origin main'
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('git push origin main'), datalink=fake)
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('git push origin main'), datalink=fake)
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('git push origin main'), datalink=stale)), 'deny')
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
- GATE_CMD = 'psql -c "select 1"' # 関門 db の見本(危険語を含まない・実行はしない)
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 本番ブランチの直接変更(★PRのマージは関門ではない → (a4) で確かめる)
44
- ('git push origin main', policy.GATE_MERGE),
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
- ('psql -c "select 1"', policy.GATE_DB),
60
- ('npx prisma migrate deploy', policy.GATE_DB),
61
- ('npx prisma db push', policy.GATE_DB),
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 = ['git push origin main', 'vercel --prod', 'npx prisma migrate deploy',
113
- 'git push -u origin master', 'npm publish', 'supabase db push',
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': 'git push origin main'}},
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': 'git push origin main'}},
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': 'git push origin main'}},
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
- 'gh api -X PUT repos/o/r/pulls/1/merge',
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')['gate'], policy.GATE_MERGE)
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('supabase db push')['gate'], policy.GATE_DB)
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
- check('複合コマンドでも関門セグメントから見出し(main直変更)',
570
- summary_ja.summarize_bash('cd /srv/app && git push origin main', 'merge'),
571
- '【お客さまの確認:本番の元を直接変更】本番の元(main)を直接書き換えてよいですか')
572
- check('複合コマンドでも関門セグメントから見出し(DB)',
573
- summary_ja.summarize_bash('cd /srv && supabase db push', 'db'),
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': 'git push origin main'},
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': 'git push origin main'},
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