@tyhld/conductor 0.11.15 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -65,10 +65,16 @@ NODE_MAJOR_WANTED=24
|
|
|
65
65
|
# 入らないまま古い版で回り続ける。どちらも 0.11.0 から入った物なので、下限をそこへ上げる。
|
|
66
66
|
# ★上げても手数は増えない。下限より古ければ、この台本が自分で
|
|
67
67
|
# `npm install -g @tyhld/conductor@latest` を打って新しくする(人の操作は不要)。
|
|
68
|
+
# 【0.12.0 へ上げた理由(えふさん確定 2026-09-16・便 93b2fa03)】
|
|
69
|
+
# 0.11.x までの番人は【読み取りを1件も判定していなかった】。`cat .env` は止まるのに、
|
|
70
|
+
# 職人の読み取りの道具で同じファイルを開くのは素通りしていた(秘密が読める穴)。
|
|
71
|
+
# 0.12.0 でその穴を塞いだが、下限が 0.11.0 のままだと 0.11.x が入っている機は
|
|
72
|
+
# 「入っています」で飛ばされ、★穴の開いた番人のまま回り続ける。だから下限を 0.12.0 にする。
|
|
73
|
+
# =この行こそが「直しをお客さま機へ届ける唯一の道」なので、下げないこと。
|
|
68
74
|
# ★食い違うと「入口は通したのに設置係が止める」が起きる。テストで同一を固定してある。
|
|
69
75
|
# ★公開 npm の latest より新しい値を書かないこと(書くと全員が設置できなくなる)。
|
|
70
76
|
# package.json の版を超えていないことをテストで固定してある。
|
|
71
|
-
MIN_CONDUCTOR_VERSION="0.
|
|
77
|
+
MIN_CONDUCTOR_VERSION="0.12.0"
|
|
72
78
|
|
|
73
79
|
# nvm の版は固定する(毎回ちがう版が降ってくると再現しない)。
|
|
74
80
|
NVM_VERSION='v0.40.1'
|
|
@@ -2,6 +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
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が開くので消さないこと。",
|
|
6
7
|
"permissions": {
|
|
7
8
|
"defaultMode": "default",
|
|
@@ -68,7 +69,35 @@
|
|
|
68
69
|
"Write(**/*.key)",
|
|
69
70
|
"Edit(**/*.key)",
|
|
70
71
|
"Write(**/.npmrc)",
|
|
71
|
-
"Edit(**/.npmrc)"
|
|
72
|
+
"Edit(**/.npmrc)",
|
|
73
|
+
"Read(**/.env)",
|
|
74
|
+
"Read(**/.env.local)",
|
|
75
|
+
"Read(**/.env.development)",
|
|
76
|
+
"Read(**/.env.production)",
|
|
77
|
+
"Read(**/.env.test)",
|
|
78
|
+
"Read(**/*.env)",
|
|
79
|
+
"Read(**/.ssh/**)",
|
|
80
|
+
"Read(**/.gnupg/**)",
|
|
81
|
+
"Read(**/.aws/**)",
|
|
82
|
+
"Read(**/.gcloud/**)",
|
|
83
|
+
"Read(**/.tyhld/**)",
|
|
84
|
+
"Read(**/*.pem)",
|
|
85
|
+
"Read(**/*.key)",
|
|
86
|
+
"Read(**/.npmrc)",
|
|
87
|
+
"Read(**/.netrc)",
|
|
88
|
+
"Read(**/.pypirc)",
|
|
89
|
+
"Read(**/id_rsa*)",
|
|
90
|
+
"Read(**/id_dsa*)",
|
|
91
|
+
"Read(**/id_ecdsa*)",
|
|
92
|
+
"Read(**/id_ed25519*)",
|
|
93
|
+
"Read(**/authorized_keys)",
|
|
94
|
+
"Read(**/known_hosts)",
|
|
95
|
+
"Read(**/credentials)",
|
|
96
|
+
"Read(**/credentials.json)",
|
|
97
|
+
"Read(**/.credentials.json)",
|
|
98
|
+
"Read(**/auth.json)",
|
|
99
|
+
"Read(**/*login.json)",
|
|
100
|
+
"Read(**/.config/gh/hosts.yml)"
|
|
72
101
|
]
|
|
73
102
|
},
|
|
74
103
|
"sandbox": {
|
package/sales-template/setup.sh
CHANGED
|
@@ -102,7 +102,7 @@ PROJECT_DIR="${2:-$HOME/projects/$SITE}"
|
|
|
102
102
|
# 食い違うと「入口は通したのに設置係が止める」が起きる。テストで同一を固定してある。
|
|
103
103
|
# ★公開 npm の latest より新しい値を書かないこと(書くと全員が設置できなくなる)。
|
|
104
104
|
# package.json の版を超えていないことをテストで固定してある。
|
|
105
|
-
MIN_CONDUCTOR_VERSION="0.
|
|
105
|
+
MIN_CONDUCTOR_VERSION="0.12.0"
|
|
106
106
|
|
|
107
107
|
# ─── ヘルパ ───────────────────────────────────────────────────────────────────
|
|
108
108
|
fail() { echo; echo "[setup] エラー: $1" >&2; exit 1; }
|
package/scripts/hooks/policy.py
CHANGED
|
@@ -752,6 +752,130 @@ def decide_write(file_path: str, cwd=None):
|
|
|
752
752
|
return _r(ALLOW, 'no gate, no danger')
|
|
753
753
|
|
|
754
754
|
|
|
755
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
756
|
+
# 入口: ファイル読み取りツール(Read / NotebookRead / 検索 / 未知の道具)
|
|
757
|
+
#
|
|
758
|
+
# 【なぜ足したか(実地の穴・便 b80b76c4 の緊急)】
|
|
759
|
+
# ここまでの判定は Bash と Write/Edit しか見ていなかった。`cat .env` は止まるのに、
|
|
760
|
+
# 職人の【読み取りの道具】で同じファイルを開くのは素通りしていた。
|
|
761
|
+
# 「秘密の名前を見分ける仕組み(secret_reason)」は既にあったのに、読む側から
|
|
762
|
+
# 一度も呼ばれていなかった=書き込みは守られ、読み取りは丸ごと開いていた。
|
|
763
|
+
#
|
|
764
|
+
# 【方針=判定点は1つ】
|
|
765
|
+
# 読み取りも書き込みも、止める理由は同じ `secret_reason(path)` から出す。
|
|
766
|
+
# ★ここに新しい秘密の一覧を作らない(写しを2つ持つと、片方に足してもう片方を
|
|
767
|
+
# 忘れる事故が必ず起きる。この製品で繰り返した型)。
|
|
768
|
+
#
|
|
769
|
+
# 【迂回経路も同じ判定を通す】
|
|
770
|
+
# ・Read / NotebookRead … file_path / notebook_path
|
|
771
|
+
# ・Grep / Glob … path(探す場所)と pattern / glob(探す名前)
|
|
772
|
+
# ・★それ以外すべての道具 … 入力に「パスらしい項目」があれば同じ判定に掛ける。
|
|
773
|
+
# MCP の道具も、将来増える道具も、名前を知らないまま同じ境界で止まる。
|
|
774
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
775
|
+
|
|
776
|
+
# 読み取り専用の道具(中身がそのまま返る)。
|
|
777
|
+
READ_TOOLS = frozenset({'Read', 'NotebookRead'})
|
|
778
|
+
|
|
779
|
+
# 探す道具(結果に中身が出る/名前だけ出る)。
|
|
780
|
+
SEARCH_TOOLS = frozenset({'Grep', 'Glob'})
|
|
781
|
+
|
|
782
|
+
# 入力の中で「1つのパス」を表す項目名。★どの道具でも同じ名前が使われる(実測)。
|
|
783
|
+
PATH_KEYS = ('file_path', 'notebook_path', 'path', 'filePath', 'file', 'target_file')
|
|
784
|
+
|
|
785
|
+
# 入力の中で「パスの並び」を表す項目名。
|
|
786
|
+
PATH_LIST_KEYS = ('file_paths', 'paths', 'files')
|
|
787
|
+
|
|
788
|
+
# 探す名前(glob)の中で使われる、パスの一部ではない記号。
|
|
789
|
+
# ★剥がしてから secret_reason に渡す=判定は1つのまま。
|
|
790
|
+
# 例 `**/.env` → `/.env` → 秘密/`**/*.ts` → `/.ts` → 秘密ではない。
|
|
791
|
+
_GLOB_META_RE = re.compile(r'\[[^\]]*\]|[*?{}]')
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def strip_glob_meta(pattern: str) -> str:
|
|
795
|
+
"""探す名前から、当てはめの記号だけを剥がして素の字面にする。
|
|
796
|
+
|
|
797
|
+
★剥がすのは記号だけ。名前そのものは1文字も変えない(緩める方向の変更はしない)。
|
|
798
|
+
`.env*` → `.env`(秘密)/`*.env.example` → `.env.example`(雛形=秘密ではない)。
|
|
799
|
+
"""
|
|
800
|
+
return _GLOB_META_RE.sub('', pattern or '')
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def glob_secret_reason(pattern: str):
|
|
804
|
+
"""探す名前が、秘密のファイルを名指ししているなら理由を返す。
|
|
805
|
+
|
|
806
|
+
★判定は secret_reason ひとつ。ここでは記号を剥がして渡すだけ。
|
|
807
|
+
"""
|
|
808
|
+
if not pattern or not isinstance(pattern, str):
|
|
809
|
+
return None
|
|
810
|
+
stripped = strip_glob_meta(pattern).strip()
|
|
811
|
+
if not stripped:
|
|
812
|
+
return None
|
|
813
|
+
return secret_reason(stripped)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def decide_read(file_path: str, cwd=None):
|
|
817
|
+
"""読み取り1件の判定。★書き込みと同じ `secret_reason` で決める。"""
|
|
818
|
+
if not file_path or not isinstance(file_path, str) or not file_path.strip():
|
|
819
|
+
return _r(ALLOW, 'empty file_path')
|
|
820
|
+
try:
|
|
821
|
+
path = norm_path(file_path, cwd)
|
|
822
|
+
except Exception: # noqa: BLE001
|
|
823
|
+
return _r(DENY, 'danger: unnormalizable path')
|
|
824
|
+
sr = secret_reason(path)
|
|
825
|
+
if sr:
|
|
826
|
+
return _r(DENY, f'danger: read from {sr}')
|
|
827
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def decide_search(tool_input, cwd=None):
|
|
831
|
+
"""探す道具(Grep / Glob)の判定。
|
|
832
|
+
|
|
833
|
+
★2つを見る。
|
|
834
|
+
① 探す【場所】が秘密のファイルそのものなら止める(中身が結果に出るため)。
|
|
835
|
+
② 探す【名前】が秘密を名指ししているなら止める。
|
|
836
|
+
★普通の探し方(リポの中を *.ts で探す 等)は1つも止めない。
|
|
837
|
+
"""
|
|
838
|
+
if not isinstance(tool_input, dict):
|
|
839
|
+
return _r(ALLOW, 'no tool_input')
|
|
840
|
+
where = tool_input.get('path')
|
|
841
|
+
if isinstance(where, str) and where.strip():
|
|
842
|
+
res = decide_read(where, cwd)
|
|
843
|
+
if res['decision'] == DENY:
|
|
844
|
+
return _r(DENY, res['reason'].replace('read from', 'search in'))
|
|
845
|
+
for key in ('glob', 'pattern'):
|
|
846
|
+
sr = glob_secret_reason(tool_input.get(key))
|
|
847
|
+
if sr:
|
|
848
|
+
return _r(DENY, f'danger: search for {sr}')
|
|
849
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def decide_any_path(tool_input, cwd=None):
|
|
853
|
+
"""★知らない道具の受け皿。入力に「パスらしい項目」があれば同じ判定に掛ける。
|
|
854
|
+
|
|
855
|
+
【なぜ要るか】道具は増える(MCP・新しい組み込み)。名前を1つずつ列挙する形だと、
|
|
856
|
+
増えたその日から素通りする。名前ではなく【入力の形】で捕まえれば、知らない道具でも
|
|
857
|
+
同じ秘密の境界で止まる。
|
|
858
|
+
★止めるのは秘密に当たったときだけ。当たらなければ今までどおり自走する。
|
|
859
|
+
"""
|
|
860
|
+
if not isinstance(tool_input, dict):
|
|
861
|
+
return _r(ALLOW, 'no tool_input')
|
|
862
|
+
for key in PATH_KEYS:
|
|
863
|
+
v = tool_input.get(key)
|
|
864
|
+
if isinstance(v, str) and v.strip():
|
|
865
|
+
res = decide_read(v, cwd)
|
|
866
|
+
if res['decision'] == DENY:
|
|
867
|
+
return _r(DENY, res['reason'].replace('read from', f'{key} points at'))
|
|
868
|
+
for key in PATH_LIST_KEYS:
|
|
869
|
+
v = tool_input.get(key)
|
|
870
|
+
if isinstance(v, (list, tuple)):
|
|
871
|
+
for item in v:
|
|
872
|
+
if isinstance(item, str) and item.strip():
|
|
873
|
+
res = decide_read(item, cwd)
|
|
874
|
+
if res['decision'] == DENY:
|
|
875
|
+
return _r(DENY, res['reason'].replace('read from', f'{key} points at'))
|
|
876
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
877
|
+
|
|
878
|
+
|
|
755
879
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
756
880
|
# 入口: ネットワーク(WebFetch)
|
|
757
881
|
# ★既定 allow。取得そのものは自走してよい。止めるのは秘密の持ち出しだが、
|
|
@@ -761,6 +885,12 @@ def decide_write(file_path: str, cwd=None):
|
|
|
761
885
|
def decide_network(url: str):
|
|
762
886
|
if not url or not isinstance(url, str) or not url.strip():
|
|
763
887
|
return _r(ALLOW, 'empty url')
|
|
888
|
+
# ★file:// は「取得」の顔をした【読み取り】。同じ秘密の境界を通す。
|
|
889
|
+
# (便 b80b76c4 の緊急・迂回経路も同じ判定を通す)
|
|
890
|
+
if url.strip().lower().startswith('file:'):
|
|
891
|
+
sr = secret_reason(re.sub(r'^file:(//)?', '', url.strip(), flags=re.IGNORECASE))
|
|
892
|
+
if sr:
|
|
893
|
+
return _r(DENY, f'danger: read from {sr} (file url)')
|
|
764
894
|
if _SECRET_ENV_RE.search(url):
|
|
765
895
|
return _r(DENY, 'danger: secret in url')
|
|
766
896
|
if re.search(r'[?&](token|secret|password|api_?key)=', url, re.IGNORECASE):
|
|
@@ -792,5 +922,13 @@ def decide_event(data):
|
|
|
792
922
|
if not fp:
|
|
793
923
|
return _r(ALLOW, 'no file_path')
|
|
794
924
|
return decide_write(fp, data.get('cwd'))
|
|
795
|
-
|
|
796
|
-
|
|
925
|
+
if tool in READ_TOOLS:
|
|
926
|
+
fp = tin.get('file_path') or tin.get('notebook_path') or ''
|
|
927
|
+
if not fp:
|
|
928
|
+
return _r(ALLOW, 'no file_path')
|
|
929
|
+
return decide_read(fp, data.get('cwd'))
|
|
930
|
+
if tool in SEARCH_TOOLS:
|
|
931
|
+
return decide_search(tin, data.get('cwd'))
|
|
932
|
+
# ★知らない道具(MCP・将来増える組み込み)も、入力にパスらしい項目があれば同じ判定を通す。
|
|
933
|
+
# 当たらなければ従来どおり自走する(WebSearch・道具の呼び出し等は1つも止まらない)。
|
|
934
|
+
return decide_any_path(tin, data.get('cwd'))
|
|
@@ -1274,6 +1274,157 @@ for cmd in REDIRECT_DENY:
|
|
|
1274
1274
|
for cmd in ['echo x > .env', 'y=$(echo x > .env)', 'echo x > "/home/fshim/.ssh/config"']:
|
|
1275
1275
|
check(f'deny {cmd[:58]}', policy.decide_bash(cmd)['decision'], policy.DENY)
|
|
1276
1276
|
|
|
1277
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1278
|
+
# (g) ★秘密ファイルの【読み取り】が、経路を問わず止まる(便 93b2fa03・えふさん確定①)
|
|
1279
|
+
#
|
|
1280
|
+
# 原則: 秘密として分類されたファイル・パスは、職人から【内容を取得できない】。
|
|
1281
|
+
# 書き込み禁止と読み取り禁止を【同じ秘密境界】で強制する。迂回経路も同じ判定を通す。
|
|
1282
|
+
# ★ここで使う名前はすべて【偽の置き場】(/tmp の下)。本物の秘密には1件も触れない。
|
|
1283
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1284
|
+
FAKE = '/tmp/claude-guard-test' # ★存在しなくてよい。判定は名前とパスだけで決まる。
|
|
1285
|
+
|
|
1286
|
+
# 秘密として止まるべきパス(名前・置き場の両方の型を1つずつ)。
|
|
1287
|
+
SECRET_PATHS = [
|
|
1288
|
+
f'{FAKE}/.env', # そのもの
|
|
1289
|
+
f'{FAKE}/.env.local', # .env. で始まる
|
|
1290
|
+
f'{FAKE}/.fake-conductor.env', # .env で【終わる】(2026-09-06 に塞いだ型)
|
|
1291
|
+
f'{FAKE}/.ssh/config', # 秘密のフォルダの中
|
|
1292
|
+
f'{FAKE}/id_rsa', # 鍵そのもの
|
|
1293
|
+
f'{FAKE}/server.pem', # 証明書
|
|
1294
|
+
f'{FAKE}/site.key', # 鍵
|
|
1295
|
+
f'{FAKE}/.npmrc', # 取得先の資格情報
|
|
1296
|
+
f'{FAKE}/auth.json', # 道具のログイン
|
|
1297
|
+
f'{FAKE}/.credentials.json', # 職人のログイン
|
|
1298
|
+
f'{FAKE}/some-login.json', # ブラウザのログイン状態
|
|
1299
|
+
f'{FAKE}/.config/gh/hosts.yml', # 置き場と対で秘密になるもの
|
|
1300
|
+
]
|
|
1301
|
+
|
|
1302
|
+
# 普通のファイル(★1つも止めてはいけない)。
|
|
1303
|
+
PLAIN_PATHS = [
|
|
1304
|
+
f'{FAKE}/src/relay.ts',
|
|
1305
|
+
f'{FAKE}/README.md',
|
|
1306
|
+
f'{FAKE}/.env.example', # 雛形は秘密ではない
|
|
1307
|
+
f'{FAKE}/example.env', # 前置きの雛形も秘密ではない
|
|
1308
|
+
f'{FAKE}/package.json',
|
|
1309
|
+
f'{FAKE}/docs/adr/ADR-001.md',
|
|
1310
|
+
]
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def ev(tool, tool_input, cwd=FAKE):
|
|
1314
|
+
return policy.decide_event({'tool_name': tool, 'tool_input': tool_input, 'cwd': cwd})
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
# (g-1) 直接の読み取り(Read / NotebookRead)
|
|
1318
|
+
for p in SECRET_PATHS:
|
|
1319
|
+
check(f'read deny Read {p[len(FAKE):][:44]}', ev('Read', {'file_path': p})['decision'], policy.DENY)
|
|
1320
|
+
check(f'read deny NotebookRead {p[len(FAKE):][:36]}',
|
|
1321
|
+
ev('NotebookRead', {'notebook_path': p})['decision'], policy.DENY)
|
|
1322
|
+
for p in PLAIN_PATHS:
|
|
1323
|
+
check(f'read allow Read {p[len(FAKE):][:44]}', ev('Read', {'file_path': p})['decision'], policy.ALLOW)
|
|
1324
|
+
|
|
1325
|
+
# (g-2) 探す道具(Grep / Glob)— 探す場所が秘密そのもの/探す名前が秘密を名指し
|
|
1326
|
+
for p in SECRET_PATHS:
|
|
1327
|
+
check(f'search deny Grep path {p[len(FAKE):][:38]}',
|
|
1328
|
+
ev('Grep', {'pattern': 'x', 'path': p})['decision'], policy.DENY)
|
|
1329
|
+
for g in ['**/.env', '.env*', '**/*.pem', '**/id_rsa*', '**/.npmrc', '**/auth.json']:
|
|
1330
|
+
check(f'search deny Grep glob {g:24}',
|
|
1331
|
+
ev('Grep', {'pattern': 'x', 'path': FAKE, 'glob': g})['decision'], policy.DENY)
|
|
1332
|
+
check(f'search deny Glob pattern {g:20}', ev('Glob', {'pattern': g})['decision'], policy.DENY)
|
|
1333
|
+
# ★普通の探し方は1つも止めない。
|
|
1334
|
+
for g in ['**/*.ts', 'src/**/*.tsx', '*', '**/*.md', '**/*.env.example']:
|
|
1335
|
+
check(f'search allow Grep glob {g:23}',
|
|
1336
|
+
ev('Grep', {'pattern': 'x', 'path': FAKE, 'glob': g})['decision'], policy.ALLOW)
|
|
1337
|
+
check(f'search allow Glob pattern {g:19}', ev('Glob', {'pattern': g})['decision'], policy.ALLOW)
|
|
1338
|
+
check('search allow Grep path=リポの中',
|
|
1339
|
+
ev('Grep', {'pattern': 'relayTick', 'path': f'{FAKE}/src'})['decision'], policy.ALLOW)
|
|
1340
|
+
|
|
1341
|
+
# (g-3) ★知らない道具(MCP・将来の組み込み)も同じ境界で止まる
|
|
1342
|
+
for p in SECRET_PATHS[:4]:
|
|
1343
|
+
check(f'unknown-tool deny path {p[len(FAKE):][:38]}',
|
|
1344
|
+
ev('mcp__fs__read_text_file', {'path': p})['decision'], policy.DENY)
|
|
1345
|
+
check(f'unknown-tool deny file_path {p[len(FAKE):][:33]}',
|
|
1346
|
+
ev('SomeFutureReader', {'file_path': p})['decision'], policy.DENY)
|
|
1347
|
+
check('unknown-tool deny paths[](並びの中の1つでも)',
|
|
1348
|
+
ev('SomeFutureReader', {'paths': [f'{FAKE}/README.md', f'{FAKE}/.env']})['decision'], policy.DENY)
|
|
1349
|
+
check('unknown-tool allow 普通のパス',
|
|
1350
|
+
ev('mcp__fs__read_text_file', {'path': f'{FAKE}/README.md'})['decision'], policy.ALLOW)
|
|
1351
|
+
check('unknown-tool allow パスの項目が無い', ev('WebSearch', {'query': '.env とは'})['decision'], policy.ALLOW)
|
|
1352
|
+
check('unknown-tool allow 説明文に字面があるだけ',
|
|
1353
|
+
ev('Task', {'prompt': 'あとで .env の書き方を調べる'})['decision'], policy.ALLOW)
|
|
1354
|
+
|
|
1355
|
+
# (g-4) file:// の取得は「取得の顔をした読み取り」
|
|
1356
|
+
check('webfetch deny file:// 秘密',
|
|
1357
|
+
ev('WebFetch', {'url': f'file://{FAKE}/.env'})['decision'], policy.DENY)
|
|
1358
|
+
check('webfetch allow file:// 普通',
|
|
1359
|
+
ev('WebFetch', {'url': f'file://{FAKE}/README.md'})['decision'], policy.ALLOW)
|
|
1360
|
+
check('webfetch allow 普通の取得',
|
|
1361
|
+
ev('WebFetch', {'url': 'https://example.com/docs'})['decision'], policy.ALLOW)
|
|
1362
|
+
|
|
1363
|
+
# (g-5) shell 経由は【従来どおり】止まる(退行していないことの確認)
|
|
1364
|
+
for cmd in ['cat .env', 'head -5 .env', 'tail .env', 'strings .env', 'xxd .env',
|
|
1365
|
+
'base64 .env', 'grep TOKEN .env', 'sed -n 1p .env', 'awk "{print}" .env',
|
|
1366
|
+
'cp .env /tmp/x', 'tar czf /tmp/x.tgz .env', 'bash -c "cat .env"',
|
|
1367
|
+
'python3 -c "print(open(\'.env\').read())"']:
|
|
1368
|
+
check(f'shell deny {cmd[:52]}', policy.decide_bash(cmd)['decision'], policy.DENY)
|
|
1369
|
+
# 普通の読み取りは止まらない。
|
|
1370
|
+
for cmd in ['cat README.md', 'grep -rn relayTick src', 'head -20 package.json',
|
|
1371
|
+
'cat .env.example', 'sed -n 1,20p src/relay.ts']:
|
|
1372
|
+
check(f'shell allow {cmd[:52]}', policy.decide_bash(cmd)['decision'], policy.ALLOW)
|
|
1373
|
+
|
|
1374
|
+
# (g-6) ★書き込みの禁止が1つも変わっていない(判定点を1つにしても後退しない)
|
|
1375
|
+
for p in SECRET_PATHS:
|
|
1376
|
+
check(f'write deny {p[len(FAKE):][:48]}', ev('Write', {'file_path': p})['decision'], policy.DENY)
|
|
1377
|
+
for p in PLAIN_PATHS:
|
|
1378
|
+
check(f'write allow {p[len(FAKE):][:47]}', ev('Write', {'file_path': p})['decision'], policy.ALLOW)
|
|
1379
|
+
|
|
1380
|
+
# (g-7) ★空振り確認=判定点(secret_reason)を外すと、上の deny が全部 allow に落ちる
|
|
1381
|
+
# これが落ちないなら「読み取りの禁止が secret_reason を通っていない」=写しの一覧を
|
|
1382
|
+
# どこかに作ってしまった合図。★判定が1か所であることを機械で固定する。
|
|
1383
|
+
_real_secret_reason = policy.secret_reason
|
|
1384
|
+
try:
|
|
1385
|
+
policy.secret_reason = lambda path: None # 判定点を無効化する
|
|
1386
|
+
broke = []
|
|
1387
|
+
for p in SECRET_PATHS:
|
|
1388
|
+
if ev('Read', {'file_path': p})['decision'] != policy.ALLOW:
|
|
1389
|
+
broke.append(f'Read {p}')
|
|
1390
|
+
if ev('Write', {'file_path': p})['decision'] != policy.ALLOW:
|
|
1391
|
+
broke.append(f'Write {p}')
|
|
1392
|
+
if ev('Grep', {'pattern': 'x', 'path': p})['decision'] != policy.ALLOW:
|
|
1393
|
+
broke.append(f'Grep {p}')
|
|
1394
|
+
if ev('mcp__fs__read_text_file', {'path': p})['decision'] != policy.ALLOW:
|
|
1395
|
+
broke.append(f'MCP {p}')
|
|
1396
|
+
for g in ['**/.env', '**/*.pem']:
|
|
1397
|
+
if ev('Glob', {'pattern': g})['decision'] != policy.ALLOW:
|
|
1398
|
+
broke.append(f'Glob {g}')
|
|
1399
|
+
if ev('WebFetch', {'url': f'file://{FAKE}/.env'})['decision'] != policy.ALLOW:
|
|
1400
|
+
broke.append('WebFetch file://')
|
|
1401
|
+
check('★空振り: 判定点を外すと読み書き探すが全部通る(=判定は1か所)', broke, [])
|
|
1402
|
+
finally:
|
|
1403
|
+
policy.secret_reason = _real_secret_reason
|
|
1404
|
+
|
|
1405
|
+
# (g-8) ★空振り確認=判定点を戻すと、また止まる(上の細工が残っていないこと)
|
|
1406
|
+
check('★空振り: 判定点を戻すと再び止まる',
|
|
1407
|
+
ev('Read', {'file_path': f'{FAKE}/.env'})['decision'], policy.DENY)
|
|
1408
|
+
|
|
1409
|
+
# (g-9) 実物のファイルで確かめる(★偽の秘密・偽の値。本物には触れない)
|
|
1410
|
+
_secret_dir = tempfile.mkdtemp(prefix='conductor-guard-read-')
|
|
1411
|
+
try:
|
|
1412
|
+
_fake_env = os.path.join(_secret_dir, '.env')
|
|
1413
|
+
with open(_fake_env, 'w', encoding='utf-8') as _f:
|
|
1414
|
+
_f.write('FAKE_TOKEN=this-is-not-a-real-secret\n')
|
|
1415
|
+
_plain = os.path.join(_secret_dir, 'notes.md')
|
|
1416
|
+
with open(_plain, 'w', encoding='utf-8') as _f:
|
|
1417
|
+
_f.write('ふつうの覚え書き\n')
|
|
1418
|
+
check('実物: 偽の秘密は読めない(Read)', ev('Read', {'file_path': _fake_env})['decision'], policy.DENY)
|
|
1419
|
+
check('実物: 偽の秘密は探せない(Grep)',
|
|
1420
|
+
ev('Grep', {'pattern': 'FAKE_TOKEN', 'path': _fake_env})['decision'], policy.DENY)
|
|
1421
|
+
check('実物: 偽の秘密は shell でも読めない',
|
|
1422
|
+
policy.decide_bash(f'cat {_fake_env}')['decision'], policy.DENY)
|
|
1423
|
+
check('実物: 同じ場所の普通のファイルは読める',
|
|
1424
|
+
ev('Read', {'file_path': _plain})['decision'], policy.ALLOW)
|
|
1425
|
+
finally:
|
|
1426
|
+
shutil.rmtree(_secret_dir, ignore_errors=True)
|
|
1427
|
+
|
|
1277
1428
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1278
1429
|
print('\n' + '=' * 72)
|
|
1279
1430
|
if failures:
|