@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.
- package/package.json +3 -1
- package/sales-template/settings.json +3 -2
- package/sales-template/setup.sh +28 -6
- package/scripts/dist-source.sh +168 -0
- package/scripts/hooks/policy.py +252 -26
- package/scripts/hooks/skill_sync_notify.py +30 -6
- package/scripts/hooks/test_policy.py +16 -3
- package/scripts/hooks/test_skill_sync_notify.py +27 -0
- package/scripts/update-guard.sh +1014 -0
- package/systemd/guard-sync-install.sh +22 -4
package/scripts/hooks/policy.py
CHANGED
|
@@ -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
|
|
58
|
-
r'\.netrc|\.npmrc|\.pypirc
|
|
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
|
|
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
|
|
129
|
-
"""
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#:
|
|
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'
|
|
121
|
+
'summary': f'{kind["summary"]}({machine})',
|
|
98
122
|
'waitMs': NOTIFY_WAIT_MS,
|
|
99
|
-
#
|
|
100
|
-
'requestKey': f'{
|
|
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',
|
|
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
|
-
|
|
978
|
-
|
|
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:
|