@tyhld/conductor 0.12.7 → 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.
@@ -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'))
@@ -45,11 +45,35 @@ import hook_datalink # noqa: E402
45
45
  #: ★1時間ごとの同期と同じ幅にしてあるので、直らない限りカードは途切れない。
46
46
  NOTIFY_WAIT_MS = 60 * 60 * 1000
47
47
 
48
- #: 同じカードを積み上げないための鍵の頭。★機体ごとに1枚だけにする。
48
+ #: 同じカードを積み上げないための鍵の頭。★機体ごと・種類ごとに1枚だけにする。
49
49
  #: 管制は同じ鍵の再登録を【寿命の延長】として扱う(upsertHookRequest)。だから
50
50
  #: 毎時の失敗でカードが増えず、直ればその回で登録が止まって数分後に自然に閉じる。
51
51
  REQUEST_KEY_PREFIX = 'craftsman-skill-stale'
52
52
 
53
+ #: 知らせの種類ごとの文面(★便 4061dd71 で2種類目を足した)。
54
+ #: 控えの `what=` で選ぶ。無ければ従来どおり鉄則の配り損ね(便 29612ff1)。
55
+ #: ★新しい知らせ方(別の口)は作らない。ここに1行足すだけで同じ確認カードに載る。
56
+ NOTICE_KINDS = {
57
+ 'craftsman-skill': {
58
+ 'key': 'craftsman-skill-stale',
59
+ 'summary': '職人の決まりが古いままです',
60
+ 'stopped': '職人の決まり(鉄則)が、新しい版に入れ替わっていません。'
61
+ '職人は古い決まりのまま動きます。',
62
+ },
63
+ 'dist-source': {
64
+ 'key': 'dist-source-unverified',
65
+ 'summary': '安全装置を配れていません',
66
+ 'stopped': '配り元(この PC に入っている采配くん本体)が公開済みの正規版だと'
67
+ '確かめられないため、番人・職人の決まり・現場の権限ルールを'
68
+ '1件も配っていません。現場は前に配られたままの状態で動きます。',
69
+ },
70
+ }
71
+
72
+
73
+ def notice_kind(rec):
74
+ """控えの what= から文面を選ぶ。知らない種類なら鉄則の配り損ねとして扱う。"""
75
+ return NOTICE_KINDS.get(rec.get('what') or 'craftsman-skill', NOTICE_KINDS['craftsman-skill'])
76
+
53
77
 
54
78
  def parse_last(path):
55
79
  """直近の試みの控えを {鍵: 値} に読む。読めなければ空。
@@ -77,9 +101,9 @@ def build_payload(rec, env=None):
77
101
  env = os.environ if env is None else env
78
102
  machine = env.get('CONDUCTOR_MACHINE') or os.uname().nodename
79
103
  reason = rec.get('reason', '不明')
104
+ kind = notice_kind(rec)
80
105
  body = '\n'.join([
81
- f'いま止まっているもの: この PC({machine})の職人の決まり(鉄則)が、'
82
- '新しい版に入れ替わっていません。職人は古い決まりのまま動きます。',
106
+ f'いま止まっているもの: この PC({machine})で、{kind["stopped"]}',
83
107
  '再開のしかた: この PC で次の1行を打ってください。'
84
108
  ' cd ~/projects/conductor そのあと APPLY=1 bash scripts/update-guard.sh',
85
109
  '★このカードは管制からは押せません(この PC での操作が要ります)。',
@@ -94,10 +118,10 @@ def build_payload(rec, env=None):
94
118
  'sessionId': hook_datalink.stable_session_id('', machine, 'guard-sync'),
95
119
  'kind': 'message',
96
120
  'question': body,
97
- 'summary': f'職人の決まりが古いままです({machine})',
121
+ 'summary': f'{kind["summary"]}({machine})',
98
122
  'waitMs': NOTIFY_WAIT_MS,
99
- # ★機体ごとに固定の鍵。毎時の失敗が同じ1枚に集約される(増殖しない)。
100
- 'requestKey': f'{REQUEST_KEY_PREFIX}:{machine}',
123
+ # ★機体ごと・種類ごとに固定の鍵。毎時の失敗が同じ1枚に集約される(増殖しない)。
124
+ 'requestKey': f'{kind["key"]}:{machine}',
101
125
  }
102
126
 
103
127
 
@@ -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)
@@ -974,8 +978,17 @@ check('同期モードは権限ルールの整理より前で終わる',
974
978
  # 人が流し忘れても、すでに配ってしまった現場の二重登録が1時間以内にそろう。
975
979
  check('同期モードは現場の二重登録外しまでは行う',
976
980
  _ug.index('③ 現場の二重登録を外す') < _ug.index('同期のみ完了しました'), True)
977
- check('同期モードは作業中(未コミット)の番人を配らない',
978
- 'status --porcelain -- scripts/hooks' in _ug, True)
981
+ # ★2026-09-16(便 4061dd71)に守り方を移した。旧=「この作業ツリーに未コミットの変更が
982
+ # 無ければ配ってよい」(=コミットさえすれば作りかけでも配れた・そもそも作業ツリーが配り元だった)。
983
+ # 新=配り元を「この機に入っている配布物で、版が公開 npm のいまの版と一致するもの」だけに限る。
984
+ # 中身の検査は test/dist-source-published-only.test.ts。
985
+ check('配り元を決める部品を通している', 'dist-source.sh' in _ug, True)
986
+ check('配り元は公開済みの正規版だけ', 'dist_source_resolve' in _ug, True)
987
+ check('配る中身はすべて配り元から取る',
988
+ all(x in _ug for x in ('GUARD_SRC_DIR="$DIST_ROOT/scripts/hooks"',
989
+ 'SETTINGS_SRC="$DIST_ROOT/sales-template/settings.json"',
990
+ 'CRAFTSMAN_SKILL_SRC="$DIST_ROOT/skills/conductor-craftsman/SKILL.md"')), True)
991
+ check('確かめられないときは配らずに終わる', 'dist_record failed' in _ug, True)
979
992
 
980
993
  _svc = os.path.join(_conductor, 'systemd', 'tyhld-guard-sync.service')
981
994
  _tmr = os.path.join(_conductor, 'systemd', 'tyhld-guard-sync.timer')
@@ -124,6 +124,33 @@ with tempfile.TemporaryDirectory() as tmp:
124
124
  # ★控えの中身(パス)以外の余計な物を載せない=秘密は1文字も入らない。
125
125
  check('本文にトークンは入らない', CFG['token'] in q, False)
126
126
 
127
+ # ── ★2種類目=配り元が正規版でない(便 4061dd71)────────────────────────────
128
+ print('\n【★2種類目=配り元の配り損ね(便 4061dd71)】')
129
+ DIST_NG = ('2026-09-16T12:00:00+0900 status=failed reason=version-drift what=dist-source '
130
+ 'version=0.12.6 published=0.12.8 root=/n/@tyhld/conductor')
131
+ with tempfile.TemporaryDirectory() as tmp:
132
+ link = FakeLink()
133
+ sn.run(write(tmp, 'dist.last', DIST_NG + '\n'), datalink=link)
134
+ p = link.sent[0]
135
+ q = p['question']
136
+ check_true('見出しが配り元の話になっている', '安全装置を配れていません' in p['summary'])
137
+ check_true('本文に「いま止まっているもの」がある', 'いま止まっているもの:' in q)
138
+ check_true('本文に「再開のしかた」がある', '再開のしかた:' in q)
139
+ check_true('何を配れていないかが書いてある', '番人' in q and '権限ルール' in q)
140
+ check_true('うまくいかなかった理由が載る', 'version-drift' in q)
141
+ check_true('鍵が種類ごとに分かれている', p['requestKey'].startswith('dist-source-unverified:'))
142
+ # ★鉄則の配り損ねとは別の鍵=2種類が同じ1枚に潰れない。
143
+ link2 = FakeLink()
144
+ sn.run(write(tmp, 'skill.last', NG_LINE + '\n'), datalink=link2)
145
+ check('鉄則の配り損ねとは別の鍵', p['requestKey'] == link2.sent[0]['requestKey'], False)
146
+ # ★知らない種類は、従来どおり鉄則の配り損ねとして扱う(増やしても壊れない)。
147
+ link3 = FakeLink()
148
+ unknown = DIST_NG.replace('what=dist-source', 'what=まだ無い種類')
149
+ sn.run(write(tmp, 'unknown.last', unknown + '\n'), datalink=link3)
150
+ check_true('知らない種類でも落ちずにカードは出る', bool(link3.sent))
151
+ check_true('知らない種類は従来の文面へ倒れる',
152
+ '職人の決まりが古いままです' in link3.sent[0]['summary'])
153
+
127
154
  # ── (f) 知らせに失敗しても同期は止めない ────────────────────────────────────
128
155
  print('\n【(f) 知らせに失敗しても同期を止めない】')
129
156
  with tempfile.TemporaryDirectory() as tmp: