Obsidianのテンプレートvaultを配る前に、.obsidianに残るノート名と鍵らしい値を検査する見本

配布用のvaultを作る前に、.obsidianに何が残るかを、合成した見本のvaultで確かめました。開いたノートの名前、検索語、鍵らしい設定値を仕込んで検査すると、見つかったのは5件です。
見本のノートはどれも架空です。ただ、ZIPに入れるつもりのないノートでも、名前だけは設定ファイルの中に残ることがあります。
隠しフォルダは、フォルダごとコピーすると付いてくる
Obsidianは、vaultの直下に.obsidianという設定用のフォルダを作ります。公式のヘルプでは、ホットキー、テーマ、コミュニティプラグインなど、そのvault固有の設定が入る場所だと説明されています。名前がドットで始まるので、ファイルマネージャーでは普通は見えません。
同じページに、workspace.jsonは「いま開いているレイアウト」を保存し、新しいノートを開くたびに更新される、という説明があります。Gitで管理するなら.gitignoreに入れてよい、とも書かれています。
workspace.jsonを.gitignoreに入れていても、ZIPを作るときはGitを通りません。フォルダごとコピーして固めれば、隠しフォルダも一緒に入ります。
AIエージェントに読ませる知識置き場としてvaultを整えると、「そのまま配れば商品になる」と思いがちです。配る側が先に確かめるのは、中身より外側かもしれません。
見本のvaultを作る
実際の作業メモを使うわけにはいかないので、合成の見本を作りました。作者の取引先を想定したClients/山田商店_見積メモ.mdは実在しない名前で、見本のvaultには入れません。それでも設定ファイルだけは、このノートを開いていた状態にします。
作るもののレイアウトは次のとおりです。
vault-template/
├── README.md
├── Templates/週次レビュー.md
└── .obsidian/
├── app.json
├── community-plugins.json
├── workspace.json
└── plugins/sample-sync/data.json
sample-syncは架空のプラグインです。data.jsonの項目名と置き場所はプラグインごとに違うので、これは私が決めた見本にすぎません。鍵の値sk-example-0000も、ただのダミーです。
見本のworkspace.jsonでは、タブのstate.fileに開いたノート、検索欄のqueryに検索語、右サイドバーのバックリンク欄にもfileを入れました。項目の位置は私が決めた見本で、Obsidianが書く実際のファイルと同じ形かは確かめていません。Obsidian本体はこのマシンに入っていないので、見本はスクリプトで書きました。
見本を作るスクリプト make_vault.py
import json
from pathlib import Path
root = Path("vault-template")
(root / ".obsidian/plugins/sample-sync").mkdir(parents=True)
(root / "Templates").mkdir()
(root / "README.md").write_text("# 週次レビュー用Vault\n", encoding="utf-8")
(root / "Templates/週次レビュー.md").write_text("## 今週やったこと\n", encoding="utf-8")
def write_json(path, data):
text = json.dumps(data, indent=2, ensure_ascii=False)
(root / ".obsidian" / path).write_text(text, encoding="utf-8")
def leaf(kind, state, title):
return {"id": "x", "type": "leaf", "state": {"type": kind, "state": state, "title": title}}
def split(tab, direction):
return {"id": "s", "type": "split", "children": [{"id": "t", "type": "tabs", "children": [tab]}], "direction": direction}
note = "Clients/山田商店_見積メモ.md"
write_json("app.json", {"attachmentFolderPath": "attachments"})
write_json("community-plugins.json", ["sample-sync"])
write_json("plugins/sample-sync/data.json", {"apiKey": "sk-example-0000", "syncFolder": "C:\\Users\\sample\\Documents\\vault", "interval": 10})
write_json("workspace.json", {
"main": split(leaf("markdown", {"file": note, "mode": "source"}, "山田商店_見積メモ"), "vertical"),
"left": split(leaf("search", {"query": "山田商店 見積"}, "Search"), "horizontal"),
"right": split(leaf("backlink", {"file": note}, "Backlinks"), "horizontal"),
"active": "x",
"lastOpenFiles": ["Templates/週次レビュー.md"],
})
作業用のディレクトリでmake_vault.pyを保存して実行すると、上のレイアウトができます。
python3 make_vault.py
find vault-template -type f | sort
vault-template/.obsidian/app.json
vault-template/.obsidian/community-plugins.json
vault-template/.obsidian/plugins/sample-sync/data.json
vault-template/.obsidian/workspace.json
vault-template/README.md
vault-template/Templates/週次レビュー.md
最初の検査は素通りした
最初に書いたのは10行の検査でした。名前に引っ張られて、lastOpenFilesだけを見ています。ここに同梱しないノートが並んでいれば止める、という考えです。
import json, sys
from pathlib import Path
root = Path(sys.argv[1])
ws = json.loads((root / ".obsidian" / "workspace.json").read_text(encoding="utf-8"))
bad = [p for p in ws.get("lastOpenFiles", []) if not (root / p).exists()]
for p in bad:
print(f"NG lastOpenFiles: {p}")
print("OK" if not bad else f"{len(bad)} 件")
sys.exit(1 if bad else 0)
これをcheck_v1.pyとして保存し、見本に流しました。
python3 check_v1.py vault-template
echo "exit=$?"
OK
exit=0
見本のlastOpenFilesには、同梱するTemplates/週次レビュー.mdしか入れていません。だから通ります。ただ、開いたノートの名前はほかにも残っています。メインのタブ、バックリンク欄、検索欄です。
この見本に最初の検査を流すと、結果はOK、終了コードは0です。ここでは「合格」の表示が、いちばん危ない出力でした。
全体を歩く検査に直す
直し方は単純です。JSONを最後の枝まで歩き、見るべき項目を拾います。
workspace.jsonのfileとlastOpenFilesが、同梱していないノートを指していないかqueryとsearchQueryに、検索語が残っていないか- 項目名に
apiKeyやtokenなどを含む文字列が、空でないか(値は画面に出さず、場所だけを表示します) - 文字列が
C:\、/Users/、/home/で始まっていないか
検査スクリプト check_vault.py
import json
import re
import sys
from pathlib import Path
SECRET_KEY = re.compile(r"api[_-]?key|token|secret|password", re.I)
ABS_PATH = re.compile(r"^[A-Za-z]:\\|^/Users/|^/home/")
def walk(node, trail=()):
if isinstance(node, dict):
for key, value in node.items():
yield from walk(value, trail + (key,))
elif isinstance(node, list):
for i, value in enumerate(node):
yield from walk(value, trail + (f"[{i}]",))
else:
yield trail, node
def main(vault):
root = Path(vault)
config = root / ".obsidian"
if not config.is_dir():
print(f"ERROR .obsidian がありません: {vault}")
return 2
problems = []
for js in sorted(config.rglob("*.json")):
rel = js.relative_to(root).as_posix()
try:
data = json.loads(js.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"ERROR {rel}: JSONとして読めません ({e.msg})")
return 2
for trail, value in walk(data):
key = str(trail[-1])
where = ".".join(trail)
if not isinstance(value, str) or not value:
continue
if js.name == "workspace.json":
if key == "file" or trail[0] == "lastOpenFiles":
if not (root / value).exists():
problems.append(f"{rel} {where}: 同梱していないノートを指す")
if key in ("query", "searchQuery"):
problems.append(f"{rel} {where}: 検索語が残っている")
if SECRET_KEY.search(key):
problems.append(f"{rel} {where}: 鍵らしい項目に値あり(値は出さない)")
if ABS_PATH.search(value):
problems.append(f"{rel} {where}: 作者環境の絶対パスらしい値")
for p in problems:
print("NG ", p)
print("OK" if not problems else f"NG {len(problems)}件")
return 1 if problems else 0
sys.exit(main(sys.argv[1]))
check_vault.pyとして保存し、見本に流します。
python3 check_vault.py vault-template
echo "exit=$?"
NG .obsidian/plugins/sample-sync/data.json apiKey: 鍵らしい項目に値あり(値は出さない)
NG .obsidian/plugins/sample-sync/data.json syncFolder: 作者環境の絶対パスらしい値
NG .obsidian/workspace.json main.children.[0].children.[0].state.state.file: 同梱していないノートを指す
NG .obsidian/workspace.json left.children.[0].children.[0].state.state.query: 検索語が残っている
NG .obsidian/workspace.json right.children.[0].children.[0].state.state.file: 同梱していないノートを指す
NG 5件
exit=1
5件です。見本は私が仕込んだ穴なので、見つかるのは当然です。大事なのは、最初の検査がOKを返した同じ見本でも、全体を歩く検査なら拾えることです。
ひとつだけ付け足します。lastOpenFilesのほうは、見本でClients/山田商店_見積メモ.mdを入れて試すと、lastOpenFiles.[0]として検出されました。
外して、悪い入力を流す
配る側のvaultからは、workspace.jsonとdata.jsonを消しました。プラグインの設定が必要なら、鍵を空にした別ファイルを置く運用が考えられます。
cp -r vault-template ship
rm ship/.obsidian/workspace.json ship/.obsidian/plugins/sample-sync/data.json
python3 check_vault.py ship
echo "exit=$?"
OK
exit=0
次に、検査そのものが黙って通らないかを確かめます。パスの打ち間違いと、壊れたJSONの2通りです。
python3 check_vault.py shipp
echo "exit=$?"
cp -r ship bad && : > bad/.obsidian/app.json
python3 check_vault.py bad
echo "exit=$?"
ERROR .obsidian がありません: shipp
exit=2
ERROR .obsidian/app.json: JSONとして読めません (Expecting value)
exit=2
0バイトのworkspace.jsonでも、同じように終了コード2で止まりました。ここは割り切りがあります。.obsidianのないフォルダも、パスの打ち間違いと区別できず、止まります。設定を一切持たないvaultを配りたい場合は、この検査をそのまま使えません。
まだ分かっていないこと
workspace.jsonを外したvaultを、Obsidianのアプリで開いてどう見えるかは、確かめていません。このマシンにObsidian本体がないためです。公式ヘルプには、workspace.jsonが開いているレイアウトを保存し、ファイルを開くたびに更新されるとありますが、ファイルがないときの扱いは書かれていません。
鍵らしい項目の判定も、apiKey、token、secret、passwordの4語だけです。プラグインがkey1のような名前を使えば、すり抜けます。ノート本文に書いた個人情報は、この検査の対象外です。
検査の対象を、vaultのフォルダではなくZIPの中身に向ける方法もあります。購入者に渡すのはフォルダではなくZIPなので、確かめる対象もZIPの側へ寄せられます。


