@tyhld/conductor 0.12.8 → 0.12.10
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 +1 -1
- package/sales-template/settings.json +3 -2
- package/scripts/hooks/policy.py +252 -26
- package/scripts/hooks/test_policy.py +14 -1
- package/scripts/update-guard.sh +43 -2
package/package.json
CHANGED
|
@@ -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(
|
|
95
|
+
"Read(**/.config/gcloud/**)",
|
|
96
|
+
"Read(**/.azure/**)",
|
|
96
97
|
"Read(**/credentials.json)",
|
|
97
98
|
"Read(**/.credentials.json)",
|
|
98
99
|
"Read(**/auth.json)",
|
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'))
|
|
@@ -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)
|
|
@@ -968,6 +972,15 @@ check('職人の鉄則は同期モードでも配る',
|
|
|
968
972
|
_ug.index('①-d 職人の鉄則スキル') < _ug.index('①-b・①-c・①-d2 は省略(同期モード)'), True)
|
|
969
973
|
check('配り損ねたら成功と偽らず管制へ知らせる',
|
|
970
974
|
'craftsman_notify_failure' in _ug and 'skill_sync_notify.py' in _ug, True)
|
|
975
|
+
# ★上書きの前に日時つきの控えを取る(えふさん確定 2026-09-16・便 bf030fee)。
|
|
976
|
+
# 中身の検査は test/craftsman-skill-sync.test.ts。ここでは決まりが消えていないことだけ見る。
|
|
977
|
+
check('上書きの前に控えを取る', 'craftsman_backup()' in _ug, True)
|
|
978
|
+
check('控えは鉄則の置き場(~/.claude)の外に置く',
|
|
979
|
+
'CRAFTSMAN_BACKUP_DIR="$HOME/.tyhld/skill-backups"' in _ug, True)
|
|
980
|
+
check('控えの名前は settings.json と同じ流儀(.bak.<日時>)',
|
|
981
|
+
'.bak.$(date' in _ug, True)
|
|
982
|
+
check('控えを取れなければ上書きしない',
|
|
983
|
+
'if ! craftsman_backup; then' in _ug and 'craftsman_record failed backup-failed' in _ug, True)
|
|
971
984
|
check('同期モードは権限ルールの整理より前で終わる',
|
|
972
985
|
_ug.index('同期のみ完了しました') < _ug.index('④ 各現場の権限ルール'), True)
|
|
973
986
|
# ★同期モードは「現場の二重登録を外す」(=番人の配線)までは行う(ADR-013)。
|
package/scripts/update-guard.sh
CHANGED
|
@@ -307,6 +307,21 @@ CRAFTSMAN_STATE="$GUARD_HOME/craftsman-skill.state" # 最後に【成功した
|
|
|
307
307
|
CRAFTSMAN_LAST="$GUARD_HOME/craftsman-skill.last" # 直近の【試み】の結果
|
|
308
308
|
CRAFTSMAN_NOTIFY="$SCRIPT_DIR/hooks/skill_sync_notify.py"
|
|
309
309
|
|
|
310
|
+
# ★上書きする前の控えの置き場(えふさん確定 2026-09-16・便 bf030fee = 29612ff1 の乙)。
|
|
311
|
+
# 【なぜ要るか】鉄則の配布は、中身が違えば無条件に上書きしていた。人が機体の鉄則を手で
|
|
312
|
+
# 直していた場合、その内容は何も残らずに消える。同じ update-guard.sh は、お客さまの
|
|
313
|
+
# settings.json を書き換えるときは必ず日時つきの控えを残す(4か所)。鉄則だけが
|
|
314
|
+
# 控えを残さない、という食い違いをここで揃える。
|
|
315
|
+
# 【★なぜ ~/.claude/skills の中に置かないか】
|
|
316
|
+
# 職人の鉄則は ~/.claude/skills/<名前>/SKILL.md として読み込まれる。控えを同じ所へ置くと、
|
|
317
|
+
# ★中身の違う鉄則が2つ並ぶ形になり、読み込まれる恐れが出る(鉄則は1台に1つが約束)。
|
|
318
|
+
# だから ~/.claude の外へ出す。置き場は番人の控えと同じ ~/.tyhld の下にする=
|
|
319
|
+
# 職人はそこへ書けない(番人が止める)ので、控えを後から書き換えられない。
|
|
320
|
+
# 【名前の流儀】settings.json の控えと同じ「<元の名前>.bak.<日時>」に揃える。
|
|
321
|
+
CRAFTSMAN_BACKUP_DIR="$HOME/.tyhld/skill-backups"
|
|
322
|
+
#: この回の控えの場所(記録に載せる)。作らなかったときは none のまま。
|
|
323
|
+
CRAFTSMAN_BACKUP="none"
|
|
324
|
+
|
|
310
325
|
# 版=この正本が入っている配布物の版(package.json)。無ければ unknown。
|
|
311
326
|
craftsman_skill_version() {
|
|
312
327
|
local pj="$DIST_ROOT/package.json"
|
|
@@ -328,7 +343,7 @@ craftsman_skill_sha() {
|
|
|
328
343
|
craftsman_record() {
|
|
329
344
|
local status="$1" reason="$2" ver="$3" sha="$4"
|
|
330
345
|
local line
|
|
331
|
-
line="$(date '+%Y-%m-%dT%H:%M:%S%z') status=${status} reason=${reason} version=${ver} sha=${sha} src=${CRAFTSMAN_SKILL_SRC} dst=${CRAFTSMAN_SKILL_DST}"
|
|
346
|
+
line="$(date '+%Y-%m-%dT%H:%M:%S%z') status=${status} reason=${reason} version=${ver} sha=${sha} backup=${CRAFTSMAN_BACKUP} src=${CRAFTSMAN_SKILL_SRC} dst=${CRAFTSMAN_SKILL_DST}"
|
|
332
347
|
mkdir -p "$GUARD_HOME" 2>/dev/null || true
|
|
333
348
|
printf '%s\n' "$line" > "$CRAFTSMAN_LAST" 2>/dev/null || true
|
|
334
349
|
if [[ "$status" == "ok" ]]; then
|
|
@@ -336,6 +351,24 @@ craftsman_record() {
|
|
|
336
351
|
fi
|
|
337
352
|
}
|
|
338
353
|
|
|
354
|
+
# 上書きする前に、いま機体に在る鉄則の控えを取る。
|
|
355
|
+
# 返り 0 … 控えは要らなかった(初めて配る)/控えを取れた
|
|
356
|
+
# 返り 1 … ★控えを取れなかった(=上書きしてはいけない)
|
|
357
|
+
# ★控えを取れたときだけ CRAFTSMAN_BACKUP に場所が入る。
|
|
358
|
+
craftsman_backup() {
|
|
359
|
+
CRAFTSMAN_BACKUP="none"
|
|
360
|
+
# 初めて配るとき(機体に鉄則が無い)は、消える物が無いので控えも作らない。
|
|
361
|
+
[[ -f "$CRAFTSMAN_SKILL_DST" ]] || return 0
|
|
362
|
+
local dst
|
|
363
|
+
dst="$CRAFTSMAN_BACKUP_DIR/conductor-craftsman-SKILL.md.bak.$(date '+%Y%m%d-%H%M%S')"
|
|
364
|
+
mkdir -p "$CRAFTSMAN_BACKUP_DIR" 2>/dev/null || return 1
|
|
365
|
+
cp -p "$CRAFTSMAN_SKILL_DST" "$dst" 2>/dev/null || return 1
|
|
366
|
+
# ★取れたと言い切る前に、中身が同じことを確かめる(空の控えを成功と呼ばない)。
|
|
367
|
+
cmp -s "$CRAFTSMAN_SKILL_DST" "$dst" 2>/dev/null || return 1
|
|
368
|
+
CRAFTSMAN_BACKUP="$dst"
|
|
369
|
+
return 0
|
|
370
|
+
}
|
|
371
|
+
|
|
339
372
|
# 配り損ねを管制へ知らせる(★届かなくても同期は止めない)。
|
|
340
373
|
craftsman_notify_failure() {
|
|
341
374
|
[[ -f "$CRAFTSMAN_NOTIFY" ]] || return 0
|
|
@@ -357,9 +390,17 @@ elif cmp -s "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null; then
|
|
|
357
390
|
info " 最新です(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA})"
|
|
358
391
|
craftsman_record ok already-current "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
|
|
359
392
|
elif [[ "$APPLY" == "1" ]]; then
|
|
360
|
-
|
|
393
|
+
# ★消える物が在るなら、上書きの【前】に控えを取る。取れなければ上書きしない。
|
|
394
|
+
if ! craftsman_backup; then
|
|
395
|
+
warn "上書きの前の控えを取れませんでした: $CRAFTSMAN_BACKUP_DIR(★鉄則は上書きしません)"
|
|
396
|
+
craftsman_record failed backup-failed "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
|
|
397
|
+
craftsman_notify_failure
|
|
398
|
+
elif mkdir -p "$(dirname "$CRAFTSMAN_SKILL_DST")" 2>/dev/null \
|
|
361
399
|
&& cp -f "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null \
|
|
362
400
|
&& cmp -s "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null; then
|
|
401
|
+
if [[ "$CRAFTSMAN_BACKUP" != "none" ]]; then
|
|
402
|
+
info " 上書きの前の控え: $CRAFTSMAN_BACKUP"
|
|
403
|
+
fi
|
|
363
404
|
info " 更新しました(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA}・この1台の全現場が共有します)"
|
|
364
405
|
craftsman_record ok applied "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
|
|
365
406
|
else
|