repo/assets は作業ツリーの中に見える。でもリンク先が repo-copy なら実体は外にある。Codexに既存リポジトリを渡す前、自分はこの差を見ておきたい。
シンボリックリンクを列挙し、解決後の行き先が作業ツリーの内側か外側かを判定する。Python標準ライブラリだけで動く。実行例には内向き・外向き・リンク切れを一つずつ作った。エージェントに渡すファイルの実体を確認する話だ。
Codexの作業ツリーで、リンクの実体を調べる
repo/assets がディレクトリへのリンクなら、ファイル一覧には repo の一部として出る。実体が隣のディレクトリなら、生成されたシェルコマンドやテストがそこへ到達しうる。読み書きできる範囲は実行環境の権限次第。それでも「作業ツリー内」と数えるのは雑だと思う。
最初はパスの文字列を比較しようとして、すぐやめた。/tmp/repo-copy は /tmp/repo で始まる。startswith() は隣のディレクトリまで内側扱いする。リンクをたどった後のパスで判定する。
audit_links.py として保存するコードはこれ。Python 3.9以降を想定している。
import os
import sys
from pathlib import Path
def audit(root: Path) -> int:
root = root.resolve(strict=True)
if not root.is_dir():
raise NotADirectoryError(root)
findings = []
for base, dirs, files in os.walk(root, followlinks=False):
dirs.sort()
files.sort()
for name in sorted((*dirs, *files)):
link = Path(base, name)
if not link.is_symlink():
continue
rel = link.relative_to(root)
try:
dest = link.resolve(strict=True)
except (OSError, RuntimeError) as exc:
findings.append((str(rel), "UNRESOLVED", type(exc).__name__))
continue
label = "IN" if dest.is_relative_to(root) else "OUT"
findings.append((str(rel), label, str(dest)))
for rel, label, dest in sorted(findings):
print(f"{label}\t{rel}\t{dest}")
return 1 if any(label != "IN" for _, label, _ in findings) else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: python3 audit_links.py DIR")
raise SystemExit(audit(Path(sys.argv[1])))
os.walk(..., followlinks=False) なら、外向きのディレクトリリンクの中へ入らない。ディレクトリリンクは dirs 側に現れるので、files だけでは取りこぼす。ここは書いていて引っかかった。resolve(strict=True) でリンク切れも UNRESOLVED として残す。後から同じ名前のファイルができると行き先が変わるからだ。
3種類のリンクを図にすると、境界はこの位置にある。
3種類のリンクと、文字列比較の失敗を再現する
次のコードは一時ディレクトリに検証用のツリーを作る。audit_links.py と同じ場所で実行した。
from pathlib import Path
from tempfile import TemporaryDirectory
from audit_links import audit
with TemporaryDirectory(prefix="codex-links-") as tmp:
base = Path(tmp)
root = base / "repo"
root.mkdir()
(root / "src").mkdir()
(root / "src" / "data.txt").write_text("sample", encoding="utf-8")
sibling = base / "repo-copy"
sibling.mkdir()
(sibling / "dummy.txt").write_text("sample", encoding="utf-8")
(root / "current").symlink_to("src/data.txt")
(root / "assets").symlink_to(sibling, target_is_directory=True)
(root / "old").symlink_to("missing.txt")
print("startswith:", str(sibling).startswith(str(root)))
print("exit:", audit(root))
手元のPython 3.14.6では startswith: True。assets が OUT、current が IN、old が UNRESOLVED、終了値は 1 だった。一時ディレクトリ名だけ省いて載せる。
startswith: True
OUT assets /.../repo-copy
IN current /.../repo/src/data.txt
UNRESOLVED old FileNotFoundError
exit: 1
実リポジトリでは python3 audit_links.py . とする。終了値 0 はリンクがすべて内向き、またはリンクがない場合。1 なら外向きか解決不能なリンクがある。CIではこの値で止められる。OUT の削除前には用途を読む。共有キャッシュなど、意図したリンクもある。
どこまで監査できたか
このスクリプトは実行時点のリンクを列挙する。監査後にリンク先が差し替わる場合、結果は保証にならない。生成コードの実行時には、OSの権限やサンドボックスも効かせる。自分なら、外向きリンクの所有者と用途を確認し、実行コマンドの入力に含まれるかを見る。
IN は安全の印ではない。作業ツリー内に秘密情報があれば、内向きリンクでもそこへ届く。判定は「作業ツリーの境界を越えるか」に絞った。この狭さを出力ラベルにも残した。
おわりに
Codexにリポジトリを渡す前の確認として、リンクの名前と解決後の行き先を並べるだけでも見落としが減る。検証用ツリーでは3種類のリンクを区別でき、文字列の前方一致が隣のディレクトリを誤判定することも再現した。まず OUT と UNRESOLVED の行をレビューする。実行時のアクセス制御は別途必要だが、少なくとも「作業ツリー内に見えていた」という思い込みはここで外せる。