@tyhld/conductor 0.12.13 → 0.12.14
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 +28 -1
- package/scripts/hooks/policy.py +374 -2
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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
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
|
+
"_note_home_secret": "★ホーム配下(リポの外)の秘密の置き場は【場所で1つずつ名指し】する(えふさん確定 2026-09-17・便 230c3175=D2)。~/ で始まる規則はホームからの場所、**/ で始まる規則は作業場所(リポ)の中だけに当たる(Claude Code 仕様)。★$HOME/** や ~/*token* のような広い書き方はしない。理由は2つ: (1) 通常の作業(npm・gh・git・build)が要るホームのファイルまで止める。(2) サンドボックスはこの一覧を OS の読み取り禁止に写すが、ホームに * を含む書き方は起動が 7〜18 秒遅くなるか、効かない(実測: ~/*/.env.local 17.5秒・~/.env* 8.5秒で効かず・~/*token* 15.7秒で効かず。場所を名指しした形は 0.25〜0.32 秒で効く)。★わざと禁止しない置き場(通常の作業に要る・中身の取り出しは番人が別に止める): ~/.npmrc(npm install が読む。npm config get の _auth 表示は番人が止める)、~/.config/gh/hosts.yml(gh と git push が読む。gh auth token・git credential fill は番人が止める)、~/.claude/session-env(Claude Code 自身が読む)、~/.bashrc・~/.profile(シェルが読む。中に秘密を置かないのが本筋)、~/.android/debug.keystore(開発用の署名)。★機体ごとに名前が違う置き場(ホーム直下の token・secret の名前のファイル、ホーム直下に残ったリポの写しの .env など)は、配る一覧では名指しできない。番人(policy.py の _home_secret_reason)が名前で止めるほか、必要なら機体の ~/.claude/settings.json に場所を足す。★フォルダごとの持ち出し(cp -r・rsync・tar・ln -s・コマンドに書いたプログラムでの複製)は番人が、写す元の中に秘密が在るかを名前だけで調べて止める。【限界】台本のファイルの中で写す形と、名前を書かずに読む子プロセスは、サンドボックスの無い現場では番人でも止まらない(OS の層が要る)。",
|
|
6
7
|
"_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
8
|
"permissions": {
|
|
8
9
|
"defaultMode": "default",
|
|
@@ -107,7 +108,33 @@
|
|
|
107
108
|
"Read(**/.credentials.json)",
|
|
108
109
|
"Read(**/auth.json)",
|
|
109
110
|
"Read(**/*login.json)",
|
|
110
|
-
"Read(**/.config/gh/hosts.yml)"
|
|
111
|
+
"Read(**/.config/gh/hosts.yml)",
|
|
112
|
+
"Read(**/.env.vercel)",
|
|
113
|
+
"Read(**/.env.*.local)",
|
|
114
|
+
"Read(~/.conductor.env)",
|
|
115
|
+
"Read(~/.conductor.env.tmp)",
|
|
116
|
+
"Read(~/.claude/.credentials.json)",
|
|
117
|
+
"Read(~/.claude/daemon/**)",
|
|
118
|
+
"Read(~/.codex/**)",
|
|
119
|
+
"Read(~/.config/gcloud/**)",
|
|
120
|
+
"Read(~/.config/google-chrome/**)",
|
|
121
|
+
"Read(~/.config/chromium/**)",
|
|
122
|
+
"Read(~/.config/kenzokun/**)",
|
|
123
|
+
"Read(~/.config/vercel-plugin/**)",
|
|
124
|
+
"Read(~/.config/stripe/**)",
|
|
125
|
+
"Read(~/.config/.wrangler/**)",
|
|
126
|
+
"Read(~/.local/share/com.vercel.cli/**)",
|
|
127
|
+
"Read(~/.supabase/**)",
|
|
128
|
+
"Read(~/.wrangler/**)",
|
|
129
|
+
"Read(~/.aws/**)",
|
|
130
|
+
"Read(~/.azure/**)",
|
|
131
|
+
"Read(~/.gnupg/**)",
|
|
132
|
+
"Read(~/.ssh/**)",
|
|
133
|
+
"Read(~/.kube/**)",
|
|
134
|
+
"Read(~/.docker/config.json)",
|
|
135
|
+
"Read(~/.netrc)",
|
|
136
|
+
"Read(~/.pypirc)",
|
|
137
|
+
"Read(~/.tyhld/ai-saku-login.json)"
|
|
111
138
|
]
|
|
112
139
|
},
|
|
113
140
|
"sandbox": {
|
package/scripts/hooks/policy.py
CHANGED
|
@@ -91,6 +91,55 @@ _LOCATION_SECRET_FILE_RE = re.compile(
|
|
|
91
91
|
r'^(credentials|known' + r'_hosts|credentials\.json)$', re.IGNORECASE
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
+
# ★リポの外(ホーム配下)の秘密の置き場(便 230c3175・D2)。
|
|
95
|
+
# 【なぜ要るか(実測 2026-09-17)】名前だけの判定では、ホーム配下の CLI の認証情報や
|
|
96
|
+
# 製品ごとの鍵の置き場(~/.supabase/access-token・~/.local/share/com.vercel.cli/auth.json・
|
|
97
|
+
# ~/.config/kenzokun/migration.env など)を「秘密」と言い切れない名前が多い。
|
|
98
|
+
# そこで【ホームからの場所】で決める。★ホームの外の同じ名前(リポの中の .codex など)は巻き込まない。
|
|
99
|
+
# ★道具ごとの置き場のうち、番人の判定に道具名を持ち込まない決め(test/guard-for-codex)に当たる物は
|
|
100
|
+
# ここに書かない(名前で当たる auth.json などは名前の判定が止める。置き場全体は配る権限ルールが止める)。
|
|
101
|
+
# ★ホーム全体($HOME/**)は止めない。職人の通常作業(npm・gh・git・Playwright)が要る置き場は
|
|
102
|
+
# ここに入れない(~/.npmrc・~/.config/gh・~/.claude/session-env・~/.tyhld/pw-venv など)。
|
|
103
|
+
_HOME_SECRET_DIRS = (
|
|
104
|
+
'.supabase', # Supabase CLI の合言葉
|
|
105
|
+
'.wrangler', '.config/.wrangler', # Cloudflare の CLI
|
|
106
|
+
'.azure', '.kube', # クラウドの CLI
|
|
107
|
+
'.claude/daemon', # Claude Code の制御用の鍵
|
|
108
|
+
'.config/kenzokun', # 建造くんのサービス用の鍵(移行用)
|
|
109
|
+
'.config/google-chrome', '.config/chromium', # ブラウザのログイン状態(Cookie など)
|
|
110
|
+
'.config/vercel-plugin', # Vercel の道具の接続状態
|
|
111
|
+
'.config/stripe', # Stripe の CLI
|
|
112
|
+
'.local/share/com.vercel.cli', # Vercel の CLI のログイン
|
|
113
|
+
)
|
|
114
|
+
#: ホームの直下に置かれたときだけ秘密と分かる名前(合言葉・鍵・設定の控え)。
|
|
115
|
+
_HOME_ROOT_SECRET_RE = re.compile(
|
|
116
|
+
r'(token|secret|passw)|^\.(bashrc|bash_profile|profile|zshrc|npmrc)\.bak|^\.conductor\.env|'
|
|
117
|
+
r'\.(pem|key|p12|pfx)$',
|
|
118
|
+
re.IGNORECASE,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _home_dir():
|
|
123
|
+
try:
|
|
124
|
+
return os.path.realpath(os.path.expanduser('~'))
|
|
125
|
+
except Exception: # noqa: BLE001
|
|
126
|
+
return ''
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _home_secret_reason(abs_path: str):
|
|
130
|
+
"""ホームからの場所だけで決まる理由(★ホームの外は見ない)。"""
|
|
131
|
+
home = _home_dir()
|
|
132
|
+
if not home or not abs_path or not abs_path.startswith(home + '/'):
|
|
133
|
+
return None
|
|
134
|
+
rel = abs_path[len(home) + 1:]
|
|
135
|
+
for d in _HOME_SECRET_DIRS:
|
|
136
|
+
if rel == d or rel.startswith(d + '/'):
|
|
137
|
+
return f'secret directory (~/{d})'
|
|
138
|
+
if '/' not in rel and _HOME_ROOT_SECRET_RE.search(rel):
|
|
139
|
+
return f'secret file (~/{rel})'
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
94
143
|
# ★機械(パッケージ管理)が入れる部品の置き場の名前。★決め打ちの一覧・大文字小文字を区別する。
|
|
95
144
|
# ここに無い名前は、どんなに依存らしく見えても部品置き場として扱わない。
|
|
96
145
|
_DEPENDENCY_DIR_NAMES = frozenset({'node_modules', 'vendor', 'site-packages', '.venv', 'venv'})
|
|
@@ -257,7 +306,12 @@ def resolved_path(path: str, cwd=None) -> str:
|
|
|
257
306
|
★これが迂回対策の土台。別名から本物へ回り込む道は、ここで正体が出る。
|
|
258
307
|
"""
|
|
259
308
|
try:
|
|
260
|
-
q =
|
|
309
|
+
q = path.strip()
|
|
310
|
+
for var in ('${HOME}', '$HOME'):
|
|
311
|
+
if q == var or q.startswith(var + '/'):
|
|
312
|
+
q = os.path.expanduser('~') + q[len(var):]
|
|
313
|
+
break
|
|
314
|
+
q = os.path.expanduser(q)
|
|
261
315
|
if not os.path.isabs(q):
|
|
262
316
|
base = cwd if (cwd and os.path.isabs(cwd)) else os.getcwd()
|
|
263
317
|
q = os.path.join(base, q)
|
|
@@ -300,6 +354,13 @@ def secret_reason(path: str, cwd=None):
|
|
|
300
354
|
if rsr:
|
|
301
355
|
return f'{rsr} (via link)'
|
|
302
356
|
|
|
357
|
+
# ★ホーム配下の秘密の置き場(場所で決まる・便 230c3175)。字面が絶対パスならそれも見る。
|
|
358
|
+
hsr = _home_secret_reason(real)
|
|
359
|
+
if not hsr and os.path.isabs(path):
|
|
360
|
+
hsr = _home_secret_reason(os.path.normpath(path))
|
|
361
|
+
if hsr:
|
|
362
|
+
return hsr
|
|
363
|
+
|
|
303
364
|
# (4) 名前だけでは言い切れないもの。★機械が入れた部品の中のときだけ通す。
|
|
304
365
|
# 字面の名前と、解いた実体の名前の【どちらか】が当たれば見る(別名で隠せない)。
|
|
305
366
|
names = [name]
|
|
@@ -629,6 +690,9 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
|
|
|
629
690
|
sr = _code_secret_reason(inner)
|
|
630
691
|
if sr:
|
|
631
692
|
return f'{sr} referenced {where}'
|
|
693
|
+
hit = _code_bulk_danger(inner, cwd)
|
|
694
|
+
if hit:
|
|
695
|
+
return f'{hit} copied {where}'
|
|
632
696
|
continue
|
|
633
697
|
# shell: 中身をコマンドとして測り直す(秘密・システム領域・危険な rm)。
|
|
634
698
|
hit = _rm_danger(inner)
|
|
@@ -636,7 +700,9 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
|
|
|
636
700
|
return f'{hit} ({where})'
|
|
637
701
|
for seg in split_segments(inner):
|
|
638
702
|
toks = tokens_of(seg)
|
|
639
|
-
|
|
703
|
+
v = verb_of(toks)
|
|
704
|
+
hit = (_secret_danger(seg, v, toks, cwd) or _bulk_copy_danger(v, toks, cwd)
|
|
705
|
+
or _recursive_read_danger(v, toks, cwd))
|
|
640
706
|
if hit:
|
|
641
707
|
return f'{hit} ({where})'
|
|
642
708
|
hit = _wrapper_danger(inner, depth + 1, cwd)
|
|
@@ -645,6 +711,288 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
|
|
|
645
711
|
return None
|
|
646
712
|
|
|
647
713
|
|
|
714
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
715
|
+
# フォルダごとの持ち出し(便 230c3175・D2)
|
|
716
|
+
#
|
|
717
|
+
# 【なぜ要るか(実測 2026-09-17・開発履歴 02d3df12)】
|
|
718
|
+
# サンドボックスの無い現場(ai-saku)の職人が、作業用の写しを作るときに、ファイル名を書かない
|
|
719
|
+
# フォルダごとの複製(rsync)で、ai-saku と meilink の .env.local・.env.vercel を中身ごと
|
|
720
|
+
# /tmp へ写していた。番人は「名前を書いたコマンド」しか止めないので素通りした。
|
|
721
|
+
# ★「元のファイルの読み取りを止めた」だけでは足りない。写しから読めてしまう。
|
|
722
|
+
#
|
|
723
|
+
# 【どう止めるか】複製・固め・同期・リンクの道具に【フォルダ】が渡されたら、そのフォルダの中を
|
|
724
|
+
# 名前と場所だけで見て回り、秘密に当たる物が1つでも在れば止める(中身は開かない)。
|
|
725
|
+
# ・除外の指定(rsync / tar の --exclude・zip の -x)で外した名前は数えない
|
|
726
|
+
# =「秘密を除いて写す」作業は通る
|
|
727
|
+
# ・依存の部品の置き場(node_modules 等)と履歴(.git)は見て回らない(量が多く、秘密の置き場ではない)
|
|
728
|
+
# ・数が多すぎて見て回り切れないときは止める(確かめられない物を通さない)
|
|
729
|
+
# 【限界】台本の【ファイル】(node copy.mjs など)の中でフォルダを写す形は、番人では止めない
|
|
730
|
+
# (台本を開いて推し量ると、例としてコマンドを含む試験のファイルまで止めてしまうため)。
|
|
731
|
+
# サンドボックスの無い現場では、ここが残る穴になる。確実に止めるのは OS の層(サンドボックス)。
|
|
732
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
733
|
+
|
|
734
|
+
#: フォルダごと持ち出せる道具。
|
|
735
|
+
_BULK_VERBS = frozenset({
|
|
736
|
+
'cp', 'rsync', 'tar', 'zip', '7z', '7za', 'scp', 'ln', 'ditto', 'cpio', 'pax',
|
|
737
|
+
'rclone', 'unison', 'bsdtar', 'gtar',
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
#: 見て回らない置き場(依存の部品・履歴・作り物)。
|
|
741
|
+
_SCAN_SKIP_DIRS = frozenset({
|
|
742
|
+
'node_modules', '.git', '.next', '.venv', 'venv', '__pycache__', '.turbo', '.cache',
|
|
743
|
+
'.pnpm-store', 'site-packages', 'coverage', '.nuxt', '.svelte-kit', '.parcel-cache',
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
#: 見て回る数の上限(これを超えたら確かめられない=止める)。
|
|
747
|
+
_SCAN_LIMIT = 20000
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _bulk_excludes(verb: str, args):
|
|
751
|
+
"""除外の指定(名前の形)を集める。"""
|
|
752
|
+
out = []
|
|
753
|
+
i = 0
|
|
754
|
+
while i < len(args):
|
|
755
|
+
a = args[i]
|
|
756
|
+
if a.startswith('--exclude='):
|
|
757
|
+
out.append(a.split('=', 1)[1])
|
|
758
|
+
elif a == '--exclude' and i + 1 < len(args):
|
|
759
|
+
out.append(args[i + 1])
|
|
760
|
+
i += 1
|
|
761
|
+
elif verb == 'zip' and a == '-x':
|
|
762
|
+
j = i + 1
|
|
763
|
+
while j < len(args) and not args[j].startswith('-'):
|
|
764
|
+
out.append(args[j])
|
|
765
|
+
j += 1
|
|
766
|
+
i = j - 1
|
|
767
|
+
i += 1
|
|
768
|
+
return [x.strip().strip('"').strip("'") for x in out if x]
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _excluded(name: str, rel: str, patterns) -> bool:
|
|
772
|
+
import fnmatch
|
|
773
|
+
for pat in patterns:
|
|
774
|
+
pat = pat.rstrip('/')
|
|
775
|
+
if fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel, pat):
|
|
776
|
+
return True
|
|
777
|
+
return False
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def find_secret_in_tree(root: str, patterns=()):
|
|
781
|
+
"""フォルダの中に秘密に当たる物があれば理由を返す(★中身は開かない)。
|
|
782
|
+
|
|
783
|
+
返り: None(無い)/理由の文字列(在る・または数が多すぎて確かめられない)。
|
|
784
|
+
"""
|
|
785
|
+
count = 0
|
|
786
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
787
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
788
|
+
keep = []
|
|
789
|
+
for d in dirnames:
|
|
790
|
+
rel = os.path.normpath(os.path.join(rel_dir, d))
|
|
791
|
+
if _excluded(d, rel, patterns):
|
|
792
|
+
continue
|
|
793
|
+
full = os.path.join(dirpath, d)
|
|
794
|
+
sr = secret_reason(full)
|
|
795
|
+
if sr:
|
|
796
|
+
return f'{sr} inside {root}'
|
|
797
|
+
if d in _SCAN_SKIP_DIRS or os.path.islink(full):
|
|
798
|
+
continue
|
|
799
|
+
keep.append(d)
|
|
800
|
+
dirnames[:] = keep
|
|
801
|
+
for f in filenames:
|
|
802
|
+
count += 1
|
|
803
|
+
if count > _SCAN_LIMIT:
|
|
804
|
+
return f'folder too large to verify for secrets ({root})'
|
|
805
|
+
rel = os.path.normpath(os.path.join(rel_dir, f))
|
|
806
|
+
if _excluded(f, rel, patterns):
|
|
807
|
+
continue
|
|
808
|
+
sr = secret_reason(os.path.join(dirpath, f))
|
|
809
|
+
if sr:
|
|
810
|
+
return f'{sr} inside {root}'
|
|
811
|
+
return None
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _existing_dir(arg: str, cwd=None):
|
|
815
|
+
"""引数が実在のフォルダ(近道を解いた先を含む)なら、その場所を返す。"""
|
|
816
|
+
if not arg or arg.startswith('-'):
|
|
817
|
+
return None
|
|
818
|
+
real = resolved_path(arg, cwd)
|
|
819
|
+
try:
|
|
820
|
+
return real if os.path.isdir(real) else None
|
|
821
|
+
except Exception: # noqa: BLE001
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _bulk_copy_danger(verb: str, tokens, cwd=None):
|
|
826
|
+
"""フォルダごとの複製・固め・同期・リンクで、中に秘密が在れば理由を返す。"""
|
|
827
|
+
if verb not in _BULK_VERBS:
|
|
828
|
+
return None
|
|
829
|
+
args = tokens[1:] if tokens else []
|
|
830
|
+
patterns = _bulk_excludes(verb, args)
|
|
831
|
+
values = [a for a in args if not a.startswith('-')]
|
|
832
|
+
# 複製先(最後の引数)は持ち出し元ではない(cp・rsync・scp・ln の形)。
|
|
833
|
+
# ★ln は「最初の引数=指す先」が持ち出し元。
|
|
834
|
+
if verb == 'ln':
|
|
835
|
+
sources = values[:1]
|
|
836
|
+
elif verb in ('cp', 'rsync', 'scp', 'ditto', 'rclone', 'unison') and len(values) >= 2:
|
|
837
|
+
sources = values[:-1]
|
|
838
|
+
else:
|
|
839
|
+
sources = values
|
|
840
|
+
for src in sources:
|
|
841
|
+
d = _existing_dir(src, cwd)
|
|
842
|
+
if not d:
|
|
843
|
+
continue
|
|
844
|
+
hit = find_secret_in_tree(d, patterns)
|
|
845
|
+
if hit:
|
|
846
|
+
return f'{hit} copied as a folder by {verb}'
|
|
847
|
+
return None
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
#: フォルダの中を名前を書かずに読む検索の道具(再帰の旗つき)。
|
|
851
|
+
_RECURSIVE_GREP_VERBS = frozenset({'grep', 'egrep', 'fgrep', 'ugrep', 'zgrep'})
|
|
852
|
+
#: 既定では隠しファイル・除外設定を飛ばすが、旗で全部読むようになる検索の道具。
|
|
853
|
+
_HIDDEN_SEARCH_VERBS = frozenset({'rg', 'ag', 'ack', 'pt'})
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _search_excludes(args):
|
|
857
|
+
out = []
|
|
858
|
+
i = 0
|
|
859
|
+
while i < len(args):
|
|
860
|
+
a = args[i]
|
|
861
|
+
for key in ('--exclude=', '--exclude-dir=', '--glob=!', '--ignore='):
|
|
862
|
+
if a.startswith(key):
|
|
863
|
+
out.append(a[len(key):])
|
|
864
|
+
if a in ('--exclude', '--exclude-dir', '--ignore') and i + 1 < len(args):
|
|
865
|
+
out.append(args[i + 1])
|
|
866
|
+
i += 1
|
|
867
|
+
elif a in ('-g', '--glob') and i + 1 < len(args) and args[i + 1].startswith('!'):
|
|
868
|
+
out.append(args[i + 1][1:])
|
|
869
|
+
i += 1
|
|
870
|
+
i += 1
|
|
871
|
+
return [x.strip().strip('"').strip("'") for x in out if x]
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _recursive_read_danger(verb: str, tokens, cwd=None):
|
|
875
|
+
"""名前を書かずにフォルダの中を読む検索で、中に秘密が在れば理由を返す。
|
|
876
|
+
|
|
877
|
+
・grep 系は -r / -R / --recursive のとき
|
|
878
|
+
・rg / ag などは隠しファイルや除外設定を無視する旗(--hidden / -u / --no-ignore など)のとき
|
|
879
|
+
(★旗が無ければ、隠しファイル(.env.local など)は既定で読まないので止めない)
|
|
880
|
+
・探す場所が書かれていなければ、作業場所(cwd)を探す場所とみなす
|
|
881
|
+
★除外の指定(--exclude=.env* など)で外した名前は数えない=「秘密を除いて探す」は通る。
|
|
882
|
+
"""
|
|
883
|
+
args = tokens[1:] if tokens else []
|
|
884
|
+
if verb in _RECURSIVE_GREP_VERBS:
|
|
885
|
+
flags = ''.join(a[1:] for a in args if a.startswith('-') and not a.startswith('--'))
|
|
886
|
+
if not ('r' in flags or 'R' in flags or '--recursive' in args
|
|
887
|
+
or '--dereference-recursive' in args):
|
|
888
|
+
return None
|
|
889
|
+
elif verb in _HIDDEN_SEARCH_VERBS:
|
|
890
|
+
loud = {'--hidden', '--no-ignore', '-u', '-uu', '-uuu', '--unrestricted', '--all-types',
|
|
891
|
+
'--skip-vcs-ignores', '-U', '--no-ignore-vcs'}
|
|
892
|
+
if not any(a in loud for a in args):
|
|
893
|
+
return None
|
|
894
|
+
else:
|
|
895
|
+
return None
|
|
896
|
+
patterns = _search_excludes(args)
|
|
897
|
+
skip = pattern_arg_index(verb, args)
|
|
898
|
+
roots = []
|
|
899
|
+
for pos, a in enumerate(args):
|
|
900
|
+
if pos == skip or a.startswith('-'):
|
|
901
|
+
continue
|
|
902
|
+
d = _existing_dir(a, cwd)
|
|
903
|
+
if d:
|
|
904
|
+
roots.append(d)
|
|
905
|
+
if not roots:
|
|
906
|
+
base = cwd if (cwd and os.path.isabs(cwd)) else os.getcwd()
|
|
907
|
+
roots = [os.path.realpath(base)]
|
|
908
|
+
for d in roots:
|
|
909
|
+
hit = find_secret_in_tree(d, patterns)
|
|
910
|
+
if hit:
|
|
911
|
+
return f'{hit} read without naming it by {verb}'
|
|
912
|
+
return None
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _find_feed_danger(command: str, cwd=None):
|
|
916
|
+
"""`find <場所> -exec cat {}` や `find <場所> | xargs cat` の形で、場所の中に秘密が在れば理由を返す。"""
|
|
917
|
+
low = command
|
|
918
|
+
if not re.search(r'\bfind\b', low):
|
|
919
|
+
return None
|
|
920
|
+
feeds = re.search(r'-exec(dir)?\b|-ok(dir)?\b|\|\s*xargs\b', low)
|
|
921
|
+
if not feeds:
|
|
922
|
+
return None
|
|
923
|
+
readers = READ_VERBS | EXFIL_VERBS | _BULK_VERBS
|
|
924
|
+
words = set(re.findall(r'[A-Za-z0-9_.-]+', low[feeds.start():]))
|
|
925
|
+
if not (words & readers):
|
|
926
|
+
return None
|
|
927
|
+
for seg in split_segments(command):
|
|
928
|
+
toks = tokens_of(seg)
|
|
929
|
+
if verb_of(toks) != 'find':
|
|
930
|
+
continue
|
|
931
|
+
args = toks[1:]
|
|
932
|
+
roots = []
|
|
933
|
+
for a in args:
|
|
934
|
+
if a.startswith('-') or a in ('(', ')', '!'):
|
|
935
|
+
break
|
|
936
|
+
d = _existing_dir(a, cwd)
|
|
937
|
+
if d:
|
|
938
|
+
roots.append(d)
|
|
939
|
+
if not roots:
|
|
940
|
+
base = cwd if (cwd and os.path.isabs(cwd)) else os.getcwd()
|
|
941
|
+
roots = [os.path.realpath(base)]
|
|
942
|
+
for d in roots:
|
|
943
|
+
hit = find_secret_in_tree(d)
|
|
944
|
+
if hit:
|
|
945
|
+
return f'{hit} read through find'
|
|
946
|
+
return None
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
#: プログラムの中で「フォルダを写す・固める」道具の呼び方(★シェルの文字列ではなく、言語の道具)。
|
|
950
|
+
_CODE_BULK_API_RE = re.compile(
|
|
951
|
+
r'cpSync|\bfs\.cp\b|\bcp\s*\(|copyFile|copytree|shutil\.copy|make_archive|'
|
|
952
|
+
r'tarfile\.open|zipfile\.ZipFile|\barchiver\s*\(|FileUtils\.cp_r',
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _code_bulk_danger(text: str, cwd=None):
|
|
957
|
+
"""コマンドに書かれたプログラムがフォルダを写す・固める形なら、写す元の中に秘密が無いか見る。
|
|
958
|
+
|
|
959
|
+
・文字列で書かれたシェルのコマンド(execSync('rsync -a . /tmp/x') など)は、コマンドとして測り直す
|
|
960
|
+
・言語の道具(fs.cpSync・shutil.copytree など)の行は、同じ行に文字列で書かれた実在のフォルダを
|
|
961
|
+
写す元とみなす。無ければ作業場所(cwd)を写す元とみなす(process.cwd() などで渡される形)
|
|
962
|
+
★台本の【ファイル】は開かない。試験のファイルなどが例としてコマンドを含むため、誤って止めるから。
|
|
963
|
+
台本のファイル経由のフォルダ複製は、OS の層(サンドボックス)でしか確実には止められない。
|
|
964
|
+
"""
|
|
965
|
+
if not text:
|
|
966
|
+
return None
|
|
967
|
+
# (a) 文字列で書かれたシェルのコマンド。
|
|
968
|
+
for m in _QUOTED_RE.finditer(text):
|
|
969
|
+
lit = m.group(1).strip()
|
|
970
|
+
toks = tokens_of(lit)
|
|
971
|
+
v = verb_of(toks)
|
|
972
|
+
if v in _BULK_VERBS:
|
|
973
|
+
hit = _bulk_copy_danger(v, toks, cwd)
|
|
974
|
+
if hit:
|
|
975
|
+
return hit
|
|
976
|
+
# (b) 言語の道具。
|
|
977
|
+
base = cwd if (cwd and os.path.isabs(cwd)) else os.getcwd()
|
|
978
|
+
tmp_real = os.path.realpath('/tmp')
|
|
979
|
+
for line in text.splitlines():
|
|
980
|
+
if not _CODE_BULK_API_RE.search(line):
|
|
981
|
+
continue
|
|
982
|
+
dirs = []
|
|
983
|
+
for m in _QUOTED_RE.finditer(line):
|
|
984
|
+
d = _existing_dir(m.group(1).strip(), cwd)
|
|
985
|
+
if d and d not in dirs and d not in ('/tmp', '/var/tmp', tmp_real):
|
|
986
|
+
dirs.append(d)
|
|
987
|
+
if not dirs:
|
|
988
|
+
dirs = [os.path.realpath(base)]
|
|
989
|
+
for d in dirs:
|
|
990
|
+
hit = find_secret_in_tree(d)
|
|
991
|
+
if hit:
|
|
992
|
+
return hit
|
|
993
|
+
return None
|
|
994
|
+
|
|
995
|
+
|
|
648
996
|
def is_inplace_edit(verb: str, tokens) -> bool:
|
|
649
997
|
"""sed -i / perl -i のような「その場書き換え」か。"""
|
|
650
998
|
if verb in ('sed', 'perl', 'ruby'):
|
|
@@ -678,6 +1026,23 @@ def _danger_patterns(low: str):
|
|
|
678
1026
|
return 'sudo rm'
|
|
679
1027
|
if re.search(r'\bprintenv\b', low):
|
|
680
1028
|
return 'printenv (環境変数の一括露出)'
|
|
1029
|
+
# ★CLI が持っている合言葉を画面に出すコマンド(便 230c3175・D2)。
|
|
1030
|
+
# gh・git・npm は職人の通常作業で自分の認証の置き場を読む必要があるので、置き場そのものは
|
|
1031
|
+
# 締め出せない(~/.config/gh/hosts.yml・~/.npmrc)。代わりに「中身を出すコマンド」を止める。
|
|
1032
|
+
if re.search(r'\bgh\s+auth\s+token\b', low):
|
|
1033
|
+
return 'gh auth token (合言葉の表示)'
|
|
1034
|
+
if re.search(r'\bgh\s+auth\s+status\b[^;&|\n]*(\s-[a-z]*t\b|--show-token)', low):
|
|
1035
|
+
return 'gh auth status --show-token (合言葉の表示)'
|
|
1036
|
+
if re.search(r'\bgh\s+auth\s+git-credential\s+get\b', low):
|
|
1037
|
+
return 'gh auth git-credential get (合言葉の表示)'
|
|
1038
|
+
if re.search(r'\bgit\s+credential(-[a-z]+)?\s+(fill|get)\b', low):
|
|
1039
|
+
return 'git credential fill (合言葉の表示)'
|
|
1040
|
+
if re.search(r'\bnpm\s+config\s+(get|list|ls)\b[^;&|\n]*_auth', low):
|
|
1041
|
+
return 'npm config get _authToken (合言葉の表示)'
|
|
1042
|
+
if re.search(r'\bvercel\s+env\s+pull\b', low):
|
|
1043
|
+
return 'vercel env pull (本番の環境設定の取り寄せ)'
|
|
1044
|
+
if re.search(r'\bsupabase\s+projects\s+api-keys\b', low):
|
|
1045
|
+
return 'supabase projects api-keys (鍵の表示)'
|
|
681
1046
|
return None
|
|
682
1047
|
|
|
683
1048
|
|
|
@@ -892,6 +1257,13 @@ def decide_bash(command: str, cwd=None):
|
|
|
892
1257
|
hit = _secret_danger(seg, v, toks, cwd)
|
|
893
1258
|
if hit:
|
|
894
1259
|
return _r(DENY, f'danger: {hit}')
|
|
1260
|
+
# ★フォルダごとの持ち出し(名前を書かない複製・固め・同期・リンク)。
|
|
1261
|
+
hit = _bulk_copy_danger(v, toks, cwd) or _recursive_read_danger(v, toks, cwd)
|
|
1262
|
+
if hit:
|
|
1263
|
+
return _r(DENY, f'danger: {hit}')
|
|
1264
|
+
hit = _find_feed_danger(command, cwd)
|
|
1265
|
+
if hit:
|
|
1266
|
+
return _r(DENY, f'danger: {hit}')
|
|
895
1267
|
|
|
896
1268
|
# ---- 1c) 危険(ラッパの中身にも同じ物差しを当てる) ----
|
|
897
1269
|
# `bash -c "cat .env"` / `python3 -c "open('.env')"` / ヒアドキュメント経由。
|