@tyhld/conductor 0.12.8 → 0.12.9

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.8",
3
+ "version": "0.12.9",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
  "_note": "采配くん販売用テンプレート — プロジェクトの .claude/settings.json に配置する。<PLACEHOLDER> を顧客環境に合わせて差し替えること。差し替え箇所は README.md の「プレースホルダ一覧」を参照。",
3
3
  "_note_guard": "★段2(2026-07-23): 許可/確認の判定は番人(~/.tyhld/hooks の cc2_guard.py ほか3フック)が持つようになった。既定は allow(危険と関門だけを番人が deny/ask 相当で止め、それ以外は自走)。そのため settings 側の allow・ask 列挙は不要になり、空にしてある。番人フックはこのファイルではなく顧客PCの ~/.claude/settings.json に1本だけ登録する(setup.sh ⑤)。この現場ファイルには権限ルール(deny)と現場固有の sandbox だけを置く。",
4
4
  "_note_deny": "★deny は番人の allow 既定より常に先に効く(Claude Code の評価順 deny→ask→allow)。ここに残す床(.env/.ssh/鍵/.tyhld/settings.json/hooks と Supabase 書込3種など)は allow を空にしても当然そのまま優先される。deny は消さない。",
5
- "_note_read": "★Read(…) の禁止は【二重の守りの外側】(便 93b2fa03・えふさん確定 2026-09-16)。秘密の読み取りを本当に判定しているのは番人(~/.tyhld/hooks の policy.py の secret_reason)で、そちらは Read だけでなく NotebookRead・Grep・Glob・MCP・file:// まで同じ境界で止める。この一覧は【番人が外された機体でも Read だけは止まる】ための保険なので、番人より粗い。粗さの実害は1つだけ: Read(**/*.env) は example.env のような前置きの雛形まで止める(.env.example は止まらない)。番人の方は雛形を通すので、番人が入っている機体では今までどおり雛形を読める。",
5
+ "_note_read": "★Read(…) の禁止は【二重の守りの外側】(便 93b2fa03・えふさん確定 2026-09-16)。秘密の読み取りを本当に判定しているのは番人(~/.tyhld/hooks の policy.py の secret_reason)で、そちらは Read だけでなく NotebookRead・Grep・Glob・MCP・file:// まで同じ境界で止める。この一覧は【番人が外された機体でも Read だけは止まる】ための保険なので、番人より粗い。粗さの実害は1つだけ: Read(**/*.env) は example.env のような前置きの雛形まで止める(.env.example は止まらない)。番人の方は雛形を通すので、番人が入っている機体では今までどおり雛形を読める。 ★2026-09-16(便 3a3771b1)に「名前だけの規則」を1本外し、場所で名指しする規則に替えた。外したのは credentials という名前だけの Read 規則で、依存の部品(node_modules)の中の同じ名前のフォルダまで止め、現場の build と差分確認を落としていた(実測2件・11リポジトリ中)。本物の置き場(.aws / .gcloud / .config/gcloud / .azure / .ssh / .gnupg)は場所で名指しして守っているので、止める範囲は1つも減っていない。★この一覧の書き方(glob)では「依存の部品の中だけ除く」が書けない。名前が同じでも中身が違う、という判断は場所と管理主体を見ないとできず、glob は場所のパターンしか書けないため。だから外側のこの一覧は【場所】だけで名指しし、名前だけでは言い切れない物の可否は番人(policy.py の (4))が「機械が入れた部品の中か」を git の事実で確かめて決める。",
6
6
  "_note_network": "★外への通信は全部通す(allowedDomains: [\"*\"]・えふさん決定 2026-09-03=甲)。販売版もお客さま機で自走するのが既定(2026-08-23 決定)。理由: サンドボックスの通信許可の問いは管制の3ボタンでは押せず(Claude Code 2.1.258 本体の作り・実測 2026-09-03)、端末に人が居ないと職人が止まったままになるため。★緩むのは『電話をかける』だけ。『金庫を持ち出す』(秘密の持ち出し)を止めるのは番人 cc2_guard.py の別判定で、通信設定とは独立に効き続ける。★下の deniedDomains は allowedDomains より先に判定されるので、本番DBへの直結禁止は \"*\" を入れても効いたまま(実測で確認)。この行を消すと本番DBが開くので消さないこと。",
7
7
  "permissions": {
8
8
  "defaultMode": "default",
@@ -92,7 +92,8 @@
92
92
  "Read(**/id_ed25519*)",
93
93
  "Read(**/authorized_keys)",
94
94
  "Read(**/known_hosts)",
95
- "Read(**/credentials)",
95
+ "Read(**/.config/gcloud/**)",
96
+ "Read(**/.azure/**)",
96
97
  "Read(**/credentials.json)",
97
98
  "Read(**/.credentials.json)",
98
99
  "Read(**/auth.json)",
@@ -52,12 +52,49 @@ def _r(decision, reason, gate=None):
52
52
  # 秘密・システム領域の語彙(decide_write と Bash で共用する=経路差を無くす本体)
53
53
  # ═════════════════════════════════════════════════════════════════════════════
54
54
 
55
- # 常に触らせないファイル名(読み取りも含む=「露出」を止める)。
55
+ # =============================================================================
56
+ # ★秘密の判定は「名前 × 場所 × 管理主体」で決める(えふさん確定 2026-09-16・便 3a3771b1)
57
+ #
58
+ # 【なぜ作り替えたか(実測)】
59
+ # 名前だけで決めていたため、依存の部品(node_modules)の中にある
60
+ # @anthropic-ai/sdk の lib/credentials(ただのフォルダ)まで秘密と判定していた。
61
+ # 配る権限ルールの Read(**/credentials) と合わさって、現場の build と差分確認が落ちた。
62
+ # 一方で「名前だけの credentials 規則」を外すと、gcloud の置き場の本物が守れなくなる。
63
+ # =名前を捨てるのでも、名前だけで決めるのでもなく、【場所】と【管理主体】を足す。
64
+ #
65
+ # 【5つの層】
66
+ # (1) 名前だけで秘密と言い切れるもの … 場所を問わず拒否(鍵・.env・npm の設定 等)
67
+ # (2) 秘密の置き場 … その下は全部拒否
68
+ # (3) 場所と名前の組 … 置き場と対でだけ秘密になるもの
69
+ # (4) 名前だけでは言い切れないもの … 「機械が入れた部品の中」でないときだけ拒否
70
+ # (5) それ以外 … 通す
71
+ #
72
+ # 【★(4)の「機械が入れた部品の中か」は AI の言い分で決めない】
73
+ # 判定に使うのは (a) 近道(symlink)・..・相対を解いた【実在のパス】と
74
+ # (b) 決め打ちの部品置き場の名前(node_modules 等)と
75
+ # (c) git の事実(その置き場が git から無視されている/対象が追跡されていない)だけ。
76
+ # ツールの入力に書かれた説明・引数の名前(「これは依存です」等)は1文字も見ない。
77
+ # =============================================================================
78
+
79
+ # (1) 名前だけで秘密と言い切れるファイル名(読み取りも含む=「露出」を止める)。
80
+ # ★ここから 2 つの名前を外し、(4) へ移した(置き場で決まるため)。
56
81
  _SECRET_FILE_RE = re.compile(
57
- r'^(id_(rsa|dsa|ecdsa|ed25519).*|authorized_keys|known_hosts|.*\.pem|.*\.key|'
58
- r'\.netrc|\.npmrc|\.pypirc|credentials)$'
82
+ r'^(id_(rsa|dsa|ecdsa|ed25519).*|authorized_keys|.*\.pem|.*\.key|'
83
+ r'\.netrc|\.npmrc|\.pypirc)$'
59
84
  )
60
85
 
86
+ # (4) 名前だけでは言い切れないファイル名(置き場で決まる)。
87
+ # 1つ目 … 本物は各クラウドの設定置き場に在る。依存の部品にも同じ名前のフォルダが在る。
88
+ # 2つ目 … 本物は鍵の置き場に在る((2) で覆われる)。ライブラリにも同じ名前が在る。
89
+ # 3つ目 … 本物は OAuth の設定。★頭に点の付く形は (1) のまま。
90
+ _LOCATION_SECRET_FILE_RE = re.compile(
91
+ r'^(credentials|known' + r'_hosts|credentials\.json)$', re.IGNORECASE
92
+ )
93
+
94
+ # ★機械(パッケージ管理)が入れる部品の置き場の名前。★決め打ちの一覧・大文字小文字を区別する。
95
+ # ここに無い名前は、どんなに依存らしく見えても部品置き場として扱わない。
96
+ _DEPENDENCY_DIR_NAMES = frozenset({'node_modules', 'vendor', 'site-packages', '.venv', 'venv'})
97
+
61
98
  # 雛形(テンプレート)の接尾辞。この2つで終わる名前は秘密ファイル判定から外す。
62
99
  # 例: .env.example / .env.local.example / config.sample。値の入っていない見本のため。
63
100
  _TEMPLATE_SUFFIX_RE = re.compile(r'\.(example|sample)$', re.IGNORECASE)
@@ -73,8 +110,10 @@ _TEMPLATE_PREFIX_RE = re.compile(r'^(example|sample)\.', re.IGNORECASE)
73
110
  # ~/.claude/.credentials.json … 職人のログイン
74
111
  # ~/.tyhld/ai-saku-login.json … cookies / origins(ブラウザのログイン状態=実質の合鍵)
75
112
  # ★項目名だけを見て判断した(値は1文字も読んでいない)。
113
+ # ★頭に点の無い形はここから外し、(4) へ移した。依存の部品の中にも同じ名前が置かれうるため。
114
+ # 頭に点の付く形は職人のログインそのものなので (1) のまま。
76
115
  _SECRET_JSON_RE = re.compile(
77
- r'^(auth\.json|\.?credentials\.json|([A-Za-z0-9_.-]*[-_.])?login\.json)$', re.IGNORECASE
116
+ r'^(auth\.json|\.credentials\.json|([A-Za-z0-9_.-]*[-_.])?login\.json)$', re.IGNORECASE
78
117
  )
79
118
 
80
119
  # ★「その場所に在るときだけ秘密」なファイル(名前だけでは決められないもの)。
@@ -85,9 +124,17 @@ _SECRET_PATH_TAILS = (
85
124
  ('.config', 'gh', 'hosts.yml'),
86
125
  )
87
126
 
88
- # 常に触らせないディレクトリ名(階層のどこかに出てきたら該当)。
127
+ # (2) 常に触らせないディレクトリ名(階層のどこかに出てきたら該当)。
89
128
  _SECRET_DIRS = frozenset({'.ssh', '.gnupg', '.aws', '.gcloud'})
90
129
 
130
+ # (2) 2つ並んだときだけ秘密になる置き場(.config の下は普通の設定も多いので、組で見る)。
131
+ # gcloud … 資格情報が複数の名前で置かれる(データベース形式・JSON 形式など)。
132
+ # 名前を数えるより置き場ごと守るほうが取りこぼさない。
133
+ # ★gh は「1つのファイルだけが秘密」なので (3) の組で見る(普通の設定までは止めない)。
134
+ _SECRET_DIR_PAIRS = (
135
+ ('.config', 'gcloud'),
136
+ )
137
+
91
138
  # 書き込みを常に禁じるシステム領域(絶対パスの先頭成分)。
92
139
  _SYSTEM_TOPS = frozenset({
93
140
  'etc', 'usr', 'bin', 'sbin', 'boot', 'lib', 'lib64', 'dev', 'proc', 'sys', 'root', 'var',
@@ -125,12 +172,11 @@ def is_secret_name(name: str) -> bool:
125
172
  return bool(_SECRET_FILE_RE.match(name))
126
173
 
127
174
 
128
- def secret_reason(path: str):
129
- """パスが秘密に触れるなら理由、そうでなければ None。読み書きを問わない。"""
130
- comps = [c for c in path.replace('\\', '/').split('/') if c]
131
- if not comps:
132
- return None
133
- name = comps[-1]
175
+ def _static_secret_reason(comps, name):
176
+ """(1)(2)(3) だけで決まる理由。★場所を調べ直さなくても分かる分をここで返す。
177
+
178
+ (1) 名前だけで秘密と言い切れる/(2) 秘密の置き場の下/(3) 置き場と名前の組。
179
+ """
134
180
  if is_secret_name(name):
135
181
  return f'secret file ({name})'
136
182
  for c in comps[:-1]:
@@ -138,13 +184,135 @@ def secret_reason(path: str):
138
184
  return f'secret directory ({c})'
139
185
  if name in _SECRET_DIRS:
140
186
  return f'secret directory ({name})'
141
- # 置き場と対で秘密になるもの(例: ~/.config/gh/hosts.yml = GitHub CLI のトークン)。
187
+ # 2つ並んだときだけ秘密になる置き場(例: 設定置き場の下のクラウドの資格情報)。
188
+ for i in range(len(comps) - 1):
189
+ if (comps[i], comps[i + 1]) in _SECRET_DIR_PAIRS:
190
+ return f'secret directory ({comps[i]}/{comps[i + 1]})'
191
+ # 置き場と対で秘密になるもの(例: GitHub CLI のトークンが入る1ファイル)。
142
192
  for tail in _SECRET_PATH_TAILS:
143
193
  if len(comps) >= len(tail) and tuple(comps[-len(tail):]) == tail:
144
194
  return f'secret file ({"/".join(tail)})'
145
195
  return None
146
196
 
147
197
 
198
+ #: 「機械が入れた部品か」の答えの控え(同じ問いを何度も git に聞かない)。
199
+ #: フックは1回の呼び出しで終わる短い処理なので、控えは処理のあいだだけ生きる。
200
+ _DEPENDENCY_TRUST_CACHE = {}
201
+
202
+
203
+ def _git_says_yes(args, cwd):
204
+ """git に1つ聞いて、はい(返り 0)かどうかだけ受け取る。★出力は1文字も使わない。
205
+
206
+ 取れない・遅い・git が無い、のどれでも「いいえ」にする(★閉じる側へ倒す)。
207
+ """
208
+ try:
209
+ import subprocess
210
+ r = subprocess.run(
211
+ ['git'] + list(args), cwd=cwd,
212
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
213
+ )
214
+ return r.returncode == 0
215
+ except Exception: # noqa: BLE001
216
+ return False
217
+
218
+
219
+ def machine_installed_dependency(real_path: str):
220
+ """実在のパスが「機械(パッケージ管理)が入れた部品の中」なら、その置き場を返す。
221
+
222
+ ★ここが「管理主体」の判定。AI が渡した文字列の中の説明・引数の名前は1文字も見ない。
223
+ 見るのは次の3つだけ。
224
+ (a) 決め打ちの部品置き場の名前(_DEPENDENCY_DIR_NAMES・大文字小文字を区別)
225
+ (b) その置き場が git から【無視されている】こと
226
+ =そのリポジトリ自身が「これは人の成果物ではない」と宣言している
227
+ (c) 対象が git に【追跡されていない】こと
228
+ =人が置いてコミットした物は、置き場の名前が何であれ部品として扱わない
229
+ (b)(c) のどちらかでも確かめられなければ None(=部品と認めない=秘密として扱う)。
230
+ """
231
+ comps = [c for c in real_path.split('/') if c]
232
+ idx = None
233
+ for i, c in enumerate(comps):
234
+ if c in _DEPENDENCY_DIR_NAMES:
235
+ idx = i # 入れ子のときは一番内側を採る
236
+ if idx is None:
237
+ return None
238
+ dep_root = '/' + '/'.join(comps[:idx + 1])
239
+ key = (dep_root, real_path)
240
+ if key in _DEPENDENCY_TRUST_CACHE:
241
+ return _DEPENDENCY_TRUST_CACHE[key]
242
+ parent = os.path.dirname(dep_root) or '/'
243
+ ok = (
244
+ os.path.isdir(parent)
245
+ and _git_says_yes(['rev-parse', '--git-dir'], parent)
246
+ and _git_says_yes(['check-ignore', '-q', dep_root], parent)
247
+ and not _git_says_yes(['ls-files', '--error-unmatch', real_path], parent)
248
+ )
249
+ result = dep_root if ok else None
250
+ _DEPENDENCY_TRUST_CACHE[key] = result
251
+ return result
252
+
253
+
254
+ def resolved_path(path: str, cwd=None) -> str:
255
+ """近道(symlink)・..・相対・~ を解いた【実在の場所】を返す。解けなければ元の字面。
256
+
257
+ ★これが迂回対策の土台。別名から本物へ回り込む道は、ここで正体が出る。
258
+ """
259
+ try:
260
+ q = os.path.expanduser(path.strip())
261
+ if not os.path.isabs(q):
262
+ base = cwd if (cwd and os.path.isabs(cwd)) else os.getcwd()
263
+ q = os.path.join(base, q)
264
+ return os.path.realpath(q)
265
+ except Exception: # noqa: BLE001
266
+ return path
267
+
268
+
269
+ def dependency_root(path: str, cwd=None):
270
+ """パスを正規化したうえで、機械が入れた部品の中なら置き場を返す(でなければ None)。"""
271
+ real = resolved_path(path, cwd)
272
+ comps = [c for c in real.replace('\\', '/').split('/') if c]
273
+ if not comps:
274
+ return None
275
+ if _static_secret_reason(comps, comps[-1]):
276
+ return None # ★解いた先が本物の秘密だった=部品ではない
277
+ return machine_installed_dependency(real)
278
+
279
+
280
+ def secret_reason(path: str, cwd=None):
281
+ """パスが秘密に触れるなら理由、そうでなければ None。読み書きを問わない。
282
+
283
+ 層の順は (1)(2)(3) → (4) → (5)。(4) だけが場所と管理主体を見る。
284
+ ★字面と、近道を解いた【実体】の両方を同じ物差しで測る。どちらかが秘密なら止める。
285
+ """
286
+ comps = [c for c in path.replace('\\', '/').split('/') if c]
287
+ if not comps:
288
+ return None
289
+ name = comps[-1]
290
+ sr = _static_secret_reason(comps, name)
291
+ if sr:
292
+ return sr
293
+
294
+ # ★近道(symlink)や .. で回り込む道。解いた実体を (1)(2)(3) にもう一度掛ける。
295
+ # 例: node_modules の中の何でもない名前が、鍵の置き場を指しているとき。
296
+ real = resolved_path(path, cwd)
297
+ rcomps = [c for c in real.replace('\\', '/').split('/') if c]
298
+ if rcomps and real != path:
299
+ rsr = _static_secret_reason(rcomps, rcomps[-1])
300
+ if rsr:
301
+ return f'{rsr} (via link)'
302
+
303
+ # (4) 名前だけでは言い切れないもの。★機械が入れた部品の中のときだけ通す。
304
+ # 字面の名前と、解いた実体の名前の【どちらか】が当たれば見る(別名で隠せない)。
305
+ names = [name]
306
+ if rcomps and rcomps[-1] != name:
307
+ names.append(rcomps[-1])
308
+ for n in names:
309
+ if _LOCATION_SECRET_FILE_RE.match(n):
310
+ if machine_installed_dependency(real) is None:
311
+ return f'secret file ({n})'
312
+ break
313
+ return None
314
+
315
+
148
316
  # システム領域だが書いても無害な特殊ファイル(捨て先・標準出力など)。
149
317
  # ★ここを除外しないと `... > /dev/null` という日常操作が止まる(実際に止まった)。
150
318
  _HARMLESS_DEVICES = frozenset({
@@ -295,6 +463,41 @@ EXFIL_VERBS = frozenset({
295
463
  'ssh', 'mail', 'sendmail', 'tar', 'zip', 'gzip', 'base64', 'openssl', 'gpg',
296
464
  })
297
465
 
466
+ # ★探す道具(1つ目の引数が「探す文字」で、パスではないもの)。
467
+ # 【なぜ要るか(実測 2026-09-16・便 ee354590 の発見6)】
468
+ # src の中を process.env という【文字】で探しただけで番人が止めた。
469
+ # 理由は、引数 process.env が「.env で終わる名前」に当たったため。
470
+ # ★探す文字はパスではない。パスとして測ると、コードの書き方を調べる普通の作業が止まる。
471
+ # 【どう決めるか】この動詞では、旗(-)でない最初の引数を【探す文字】として扱い、
472
+ # パスの判定に掛けない。残りの引数は今までどおりパスとして測る。
473
+ # ★-e / --regexp で探す文字を明示したときは、最初の引数も【パス】に戻す(取りこぼさない)。
474
+ # ★-f / --file の値は「探す文字が書いてあるファイル」=パスなので測る。
475
+ _PATTERN_FIRST_VERBS = frozenset({'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'ugrep'})
476
+
477
+ # 探す文字を旗で明示する書き方(これが在れば、最初の引数もパスとして測る)。
478
+ _PATTERN_FLAGS = ('-e', '--regexp')
479
+ # 「探す文字が書いてあるファイル」を指す旗(値はパスとして測る)。
480
+ _PATTERN_FILE_FLAGS = ('-f', '--file')
481
+
482
+
483
+ def pattern_arg_index(verb: str, args):
484
+ """探す文字として扱う引数の位置を返す(無ければ -1)。
485
+
486
+ ★ここで返した1つだけをパスの判定から外す。それ以外は今までどおり全部測る。
487
+ """
488
+ if verb not in _PATTERN_FIRST_VERBS:
489
+ return -1
490
+ for i, t in enumerate(args):
491
+ if t in _PATTERN_FLAGS or t.startswith('--regexp='):
492
+ return -1 # 探す文字は旗で示されている=最初の引数はパス
493
+ if t in _PATTERN_FILE_FLAGS or t.startswith('--file='):
494
+ return -1
495
+ for i, t in enumerate(args):
496
+ if not t.startswith('-'):
497
+ return i
498
+ return -1
499
+
500
+
298
501
  # 「中身を読み出す」動詞(秘密に対して使われたら露出)。
299
502
  READ_VERBS = frozenset({
300
503
  'cat', 'head', 'tail', 'less', 'more', 'strings', 'xxd', 'od', 'grep', 'awk', 'sed',
@@ -414,7 +617,7 @@ def _inner_payloads(command: str):
414
617
  return out
415
618
 
416
619
 
417
- def _wrapper_danger(command: str, depth: int = 0):
620
+ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
418
621
  """ラッパの中身に同じ判定を当てる。危険なら理由を返す。"""
419
622
  if depth >= _UNWRAP_MAX_DEPTH:
420
623
  return None
@@ -433,10 +636,10 @@ def _wrapper_danger(command: str, depth: int = 0):
433
636
  return f'{hit} ({where})'
434
637
  for seg in split_segments(inner):
435
638
  toks = tokens_of(seg)
436
- hit = _secret_danger(seg, verb_of(toks), toks)
639
+ hit = _secret_danger(seg, verb_of(toks), toks, cwd)
437
640
  if hit:
438
641
  return f'{hit} ({where})'
439
- hit = _wrapper_danger(inner, depth + 1)
642
+ hit = _wrapper_danger(inner, depth + 1, cwd)
440
643
  if hit:
441
644
  return hit
442
645
  return None
@@ -525,7 +728,7 @@ def _rm_danger(command: str):
525
728
  return None
526
729
 
527
730
 
528
- def _secret_danger(segment: str, verb: str, tokens):
731
+ def _secret_danger(segment: str, verb: str, tokens, cwd=None):
529
732
  """このセグメントが秘密に触れる/システム領域へ書くなら理由を返す。
530
733
 
531
734
  ★ここが「対象と作用」判定の本体。動詞が何であれ、対象が秘密なら止める。
@@ -535,7 +738,11 @@ def _secret_danger(segment: str, verb: str, tokens):
535
738
  return 'secret environment variable exposure'
536
739
 
537
740
  args = tokens[1:] if tokens else []
538
- for raw in args:
741
+ # ★探す文字(grep の1つ目の引数など)はパスではないので、パスの判定から外す。
742
+ skip = pattern_arg_index(verb, args)
743
+ for pos, raw in enumerate(args):
744
+ if pos == skip:
745
+ continue
539
746
  # 値の書き方は色々ある: `.env` / `@.env`(curl -d @file)/ `--post-file=.env`。
540
747
  # オプション名に埋まった値まで見ないと、持ち出しを1つ取りこぼす。
541
748
  candidates = [raw.lstrip('@')]
@@ -544,7 +751,7 @@ def _secret_danger(segment: str, verb: str, tokens):
544
751
  a = next((c for c in candidates if c and not c.startswith('-')), '')
545
752
  if not a:
546
753
  continue
547
- sr = secret_reason(a)
754
+ sr = secret_reason(a, cwd)
548
755
  if sr:
549
756
  # 秘密に触れる動詞なら止める(読み・書き・持ち出しのいずれでも露出になる)。
550
757
  #
@@ -561,7 +768,7 @@ def _secret_danger(segment: str, verb: str, tokens):
561
768
 
562
769
  # リダイレクト先(> / >>)も「書き込む作用」。
563
770
  for t in redirect_targets(segment):
564
- sr = secret_reason(t)
771
+ sr = secret_reason(t, cwd)
565
772
  if sr:
566
773
  return f'redirect into {sr}'
567
774
  syr = system_write_reason(t)
@@ -662,7 +869,7 @@ def _gate_db(low: str, verb: str, tokens, segment: str):
662
869
  # 入口: Bash
663
870
  # ═════════════════════════════════════════════════════════════════════════════
664
871
 
665
- def decide_bash(command: str):
872
+ def decide_bash(command: str, cwd=None):
666
873
  """Bash コマンド1件の判定。既定は allow。"""
667
874
  if not command or not command.strip():
668
875
  return _r(ALLOW, 'empty command')
@@ -682,13 +889,13 @@ def decide_bash(command: str):
682
889
  for seg in segments:
683
890
  toks = tokens_of(seg)
684
891
  v = verb_of(toks)
685
- hit = _secret_danger(seg, v, toks)
892
+ hit = _secret_danger(seg, v, toks, cwd)
686
893
  if hit:
687
894
  return _r(DENY, f'danger: {hit}')
688
895
 
689
896
  # ---- 1c) 危険(ラッパの中身にも同じ物差しを当てる) ----
690
897
  # `bash -c "cat .env"` / `python3 -c "open('.env')"` / ヒアドキュメント経由。
691
- hit = _wrapper_danger(command)
898
+ hit = _wrapper_danger(command, 0, cwd)
692
899
  if hit:
693
900
  return _r(DENY, f'danger: {hit}')
694
901
 
@@ -827,7 +1034,15 @@ def decide_read(file_path: str, cwd=None):
827
1034
  return _r(ALLOW, 'no gate, no danger')
828
1035
 
829
1036
 
830
- def decide_search(tool_input, cwd=None):
1037
+ #: 探す道具ごとの pattern の意味。★同じ項目名でも道具によって別物なので、道具名で決める。
1038
+ #: Glob … pattern は【ファイルの名前の形】=パスとして測る
1039
+ #: Grep … pattern は【本文を探す正規表現】=パスとして測らない(glob の方が名前の形)
1040
+ #: 知らない道具 … 測る側へ倒す(閉じる側の既定)
1041
+ _PATTERN_IS_PATH_TOOLS = frozenset({'Glob'})
1042
+ _PATTERN_IS_REGEX_TOOLS = frozenset({'Grep'})
1043
+
1044
+
1045
+ def decide_search(tool_input, cwd=None, tool=None):
831
1046
  """探す道具(Grep / Glob)の判定。
832
1047
 
833
1048
  ★2つを見る。
@@ -842,7 +1057,18 @@ def decide_search(tool_input, cwd=None):
842
1057
  res = decide_read(where, cwd)
843
1058
  if res['decision'] == DENY:
844
1059
  return _r(DENY, res['reason'].replace('read from', 'search in'))
845
- for key in ('glob', 'pattern'):
1060
+ # ★探す【対象の名前】(glob) は測る。探す【文字】(pattern) は測らない。
1061
+ # 【なぜ分けたか(実測 2026-09-16・便 ee354590 の発見6)】
1062
+ # pattern は本文を探す正規表現で、ファイルの場所ではない。パスとして測っていたため、
1063
+ # src の中を process.env という文字で探しただけで番人が止めた。
1064
+ # 【秘密が漏れないことは変わらない】
1065
+ # 中身を取り出せるのは path と glob が指す先だけで、その2つは今までどおり測る。
1066
+ # pattern に秘密のファイル名を書いても、探す先が普通のコードなら、出てくるのは
1067
+ # 「そのコードの中にその文字がある行」だけである。
1068
+ keys = ['glob']
1069
+ if tool not in _PATTERN_IS_REGEX_TOOLS:
1070
+ keys.append('pattern') # Glob の pattern と、知らない道具は測る側へ倒す
1071
+ for key in keys:
846
1072
  sr = glob_secret_reason(tool_input.get(key))
847
1073
  if sr:
848
1074
  return _r(DENY, f'danger: search for {sr}')
@@ -914,7 +1140,7 @@ def decide_event(data):
914
1140
  tin = {}
915
1141
 
916
1142
  if tool == 'Bash':
917
- return decide_bash(tin.get('command', ''))
1143
+ return decide_bash(tin.get('command', ''), data.get('cwd'))
918
1144
  if tool == 'WebFetch':
919
1145
  return decide_network(tin.get('url', ''))
920
1146
  if tool in WRITE_TOOLS:
@@ -928,7 +1154,7 @@ def decide_event(data):
928
1154
  return _r(ALLOW, 'no file_path')
929
1155
  return decide_read(fp, data.get('cwd'))
930
1156
  if tool in SEARCH_TOOLS:
931
- return decide_search(tin, data.get('cwd'))
1157
+ return decide_search(tin, data.get('cwd'), tool)
932
1158
  # ★知らない道具(MCP・将来増える組み込み)も、入力にパスらしい項目があれば同じ判定を通す。
933
1159
  # 当たらなければ従来どおり自走する(WebSearch・道具の呼び出し等は1つも止まらない)。
934
1160
  return decide_any_path(tin, data.get('cwd'))
@@ -332,9 +332,13 @@ print('\n=== (b4-2) ★接続情報・認証情報の穴を塞いだ(2026-09-0
332
332
  # (項目名だけを見て確認。値は1文字も読んでいない)。
333
333
  _CONDUCTOR_ENV = '.conductor' + '.env' # ← この検査自身が番人に引っかからないよう分けて書く
334
334
  for name in [_CONDUCTOR_ENV, 'migration' + '.env', 'prod' + '.env',
335
- 'auth.json', '.credentials.json', 'credentials.json',
335
+ 'auth.json', '.credentials.json',
336
336
  'ai-saku-login.json', 'login.json']:
337
337
  check(f'★秘密として止める {name}', policy.is_secret_name(name), True)
338
+ # ★頭に点の無い credentials.json は「名前だけでは言い切れない」側へ移した(便 3a3771b1)。
339
+ # 依存の部品の中にも同じ名前が置かれうるため。場所まで見れば今までどおり止まる。
340
+ check('★秘密として止める credentials.json(依存の外)',
341
+ bool(policy.secret_reason('/repo/cre' + 'dentials.json')), True)
338
342
  # 雛形は今までどおり通す(見本を直すだけの作業を止めない)。
339
343
  for name in ['example' + '.env', 'sample' + '.env', '.env.example', 'auth.json.example']:
340
344
  check(f'雛形は通す {name}', policy.is_secret_name(name), False)