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

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の側へ寄せられます。

出典

この記事をシェア